feat(core): add session form service (#34855)
This commit is contained in:
parent
460cdc5aec
commit
7ebd344fa2
67 changed files with 7862 additions and 5223 deletions
|
|
@ -2,9 +2,10 @@ export * as QuestionTool from "./question"
|
|||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { optional } from "@opencode-ai/schema/schema"
|
||||
import { Form } from "../form"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
|
@ -22,19 +23,40 @@ Usage notes:
|
|||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
|
||||
description: Schema.String.annotate({ description: "Explanation of choice" }),
|
||||
}).annotate({ identifier: "QuestionTool.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
|
||||
export const Prompt = Schema.Struct({
|
||||
question: Schema.String.annotate({ description: "Complete question" }),
|
||||
header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
|
||||
options: Schema.Array(Option).annotate({ description: "Available choices" }),
|
||||
multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
|
||||
custom: Schema.Boolean.pipe(optional).annotate({ description: "Allow typing a custom answer (default: true)" }),
|
||||
}).annotate({ identifier: "QuestionTool.Prompt" })
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
||||
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionTool.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.Array(Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
answers: Schema.Array(Answer),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
answers: ReadonlyArray<QuestionV2.Answer>,
|
||||
) => {
|
||||
export class RejectedError extends Error {
|
||||
constructor() {
|
||||
super("The user dismissed this question")
|
||||
}
|
||||
}
|
||||
|
||||
export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: ReadonlyArray<Answer>) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
(question, index) =>
|
||||
|
|
@ -47,7 +69,7 @@ export const toModelOutput = (
|
|||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
const form = yield* Form.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
|
|
@ -71,15 +93,22 @@ const layer = Layer.effectDiscard(
|
|||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.andThen(
|
||||
question
|
||||
form
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
questions: input.questions,
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
title: input.questions.length === 1 ? input.questions[0]?.header : "Questions",
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
},
|
||||
mode: "form",
|
||||
fields: input.questions.map(questionToField),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
Effect.flatMap((state) =>
|
||||
state.status === "answered" ? Effect.succeed({ answers: formToAnswers(input.questions, state.answer) }) : Effect.die(new RejectedError()),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
@ -90,5 +119,33 @@ const layer = Layer.effectDiscard(
|
|||
export const node = makeLocationNode({
|
||||
name: "tool/question",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
|
||||
deps: [ToolRegistry.node, PermissionV2.node, Form.node],
|
||||
})
|
||||
|
||||
function questionToField(question: Prompt, index: number): Form.Field {
|
||||
const base = {
|
||||
key: key(index),
|
||||
title: question.question,
|
||||
description: question.header,
|
||||
}
|
||||
const options = question.options.map((option) => ({
|
||||
value: option.label,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
if (question.multiple) return { ...base, type: "multiselect", options, custom: question.custom ?? true }
|
||||
return { ...base, type: "string", options, custom: question.custom ?? true }
|
||||
}
|
||||
|
||||
function formToAnswers(questions: ReadonlyArray<Prompt>, answer: Form.Answer): ReadonlyArray<Answer> {
|
||||
return questions.map((_, index) => {
|
||||
const value = answer[key(index)]
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string" && value.length > 0) return [value]
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function key(index: number) {
|
||||
return `question_${index}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue