-
Notifications
You must be signed in to change notification settings - Fork 11
feat(documents): add interactive widgets to documents #725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3041178
7622822
3021fc0
930fd6d
34f2f6b
a739558
13f53a6
9b9cff3
31ce60d
67afb5c
f4e4778
817176e
ca61ab3
4f7917b
cc47229
3cd907c
bc7136b
f6c67e4
030763c
17970a8
7087704
9fdc6ce
998a628
049722d
eb4fb21
9c720bf
f20098e
71d0ee5
1d8b93a
487217f
307c1cd
6292f7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import type { z } from "zod"; | |
| import { getAIThreadSoulPrompt } from "#/features/workspaces/ai/ai-thread-soul-prompt"; | ||
| import { createProviderCompatibleInputSchema } from "#/features/workspaces/ai/ai-thread-tool"; | ||
| import { | ||
| getAIThreadSystemPromptForWorkspace, | ||
| getWorkspaceAiGatewayProviderOptions, | ||
| getWorkspaceAiLanguageModel, | ||
| } from "#/features/workspaces/ai/ai-thread-runtime"; | ||
|
|
@@ -16,13 +17,20 @@ import { | |
| getWorkspaceToolDefinition, | ||
| workspaceToolDefinitions, | ||
| } from "#/features/workspaces/operations/workspace-tool-definitions"; | ||
| import { | ||
| createDocumentAiBlockSnapshot, | ||
| parseDocumentAiHtml, | ||
| serializeTiptapNodeToAiHtml, | ||
| withTiptapNodeAiRef, | ||
| } from "#/features/workspaces/documents/document-ai-html"; | ||
| import { getTiptapDocumentSchema } from "#/features/workspaces/documents/tiptap-schema"; | ||
|
|
||
| /** A single tool call the model emitted, graded against the real zod schema. */ | ||
| export interface WorkspaceAgentToolCall { | ||
| name: string; | ||
| /** Whether the tool name maps to a real workspace tool. */ | ||
| known: boolean; | ||
| input: unknown; | ||
| /** editRefs returned by completed read steps before this call, grouped by source path. */ | ||
| priorReadEditRefsByPath: Record<string, string[]>; | ||
| /** `input` satisfies the tool's real zod input schema. */ | ||
| valid: boolean; | ||
| /** Human-readable zod issues (`path: message`) when invalid. */ | ||
|
|
@@ -32,101 +40,169 @@ export interface WorkspaceAgentToolCall { | |
| /** Normalized, JSON-safe result of one agent turn — the harness `output`. */ | ||
| export interface WorkspaceAgentOutput { | ||
| text: string; | ||
| finishReason: string; | ||
| toolCalls: WorkspaceAgentToolCall[]; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| export interface WorkspaceAgentInput { | ||
| prompt: string; | ||
| /** Friendly model id from `models.ts` (e.g. "claude-sonnet"). Defaults to "auto". */ | ||
| modelId?: string; | ||
| /** Extra system text appended to the soul prompt (e.g. a workspace scope block). */ | ||
| system?: string; | ||
| /** Whether the turn may mutate; drives the real runtime scope block. */ | ||
| canMutate?: boolean; | ||
| } | ||
|
|
||
| // Deterministic read fixture: document HTML carrying real `data-ref` values, so a | ||
| // read→edit turn can produce a *targeted* edit whose ref traces back to the read. | ||
| // `scoreTargetedEditProvenance` checks that provenance against these refs. | ||
| const STANDUP_HEADING_REF = "b_standupHead1.r_head000001"; | ||
| const STANDUP_LIST_REF = "b_standupList1.r_bullet0001"; | ||
| export const EVAL_READ_FIXTURE_REFS = [STANDUP_HEADING_REF, STANDUP_LIST_REF]; | ||
|
|
||
| // Per-tool stubbed outputs. Reads return editable HTML + refs; everything else | ||
| // returns a neutral success so a follow-up step can still proceed. No real | ||
| // Durable Object is touched and no workspace is mutated. | ||
| const EVAL_TOOL_FIXTURES: Record<string, unknown> = { | ||
| workspace_read_items: { | ||
| items: [ | ||
| { | ||
| path: "/Notes/Standup.md", | ||
| type: "document", | ||
| html: `<h1 data-ref="${STANDUP_HEADING_REF}">Standup</h1><ul data-ref="${STANDUP_LIST_REF}"><li>Discuss roadmap</li></ul>`, | ||
| }, | ||
| ], | ||
| }, | ||
| const STANDUP_PATH = "/Notes/Standup.md"; | ||
|
|
||
| type EvalStandupFixture = { | ||
| blocks: Map<string, string>; | ||
| content: string; | ||
| }; | ||
|
|
||
| function evalToolFixture(toolName: string): unknown { | ||
| return EVAL_TOOL_FIXTURES[toolName] ?? { ok: true, note: "eval stub — no real mutation" }; | ||
| let evalStandupFixture: Promise<EvalStandupFixture> | undefined; | ||
|
|
||
| function getEvalStandupFixture() { | ||
| return (evalStandupFixture ??= createEvalStandupFixture()); | ||
| } | ||
|
|
||
| /** Derive the eval read fixture from the production serializers so it cannot drift. */ | ||
| async function createEvalStandupFixture(): Promise<EvalStandupFixture> { | ||
| const document = getTiptapDocumentSchema().nodeFromJSON( | ||
| parseDocumentAiHtml("<h1>Standup</h1><ul><li>Discuss roadmap</li></ul>"), | ||
| ); | ||
| const blockIds = ["b_standupHead1", "b_standupList1"]; | ||
| const blocks = new Map<string, string>(); | ||
| const content: string[] = []; | ||
|
|
||
| for (let index = 0; index < document.childCount; index += 1) { | ||
| const blockId = blockIds[index]; | ||
| if (!blockId) throw new Error("Eval standup fixture has an unexpected block count."); | ||
| const node = withTiptapNodeAiRef(document.child(index), blockId); | ||
| const snapshot = await createDocumentAiBlockSnapshot(node); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · This makes the for-loop slow because each await runs one after another, so collect the independent calls & run them together with Fix → Collect the items, then use |
||
| blocks.set(snapshot.editRef, snapshot.content); | ||
| content.push(await serializeTiptapNodeToAiHtml(node)); | ||
| } | ||
|
|
||
| return { blocks, content: content.join("") }; | ||
| } | ||
|
|
||
| // Per-tool stubbed outputs. Reads return the realistic item for the requested | ||
| // path; everything else returns a neutral success so a follow-up step can still | ||
| // proceed. No real Durable Object is touched and no workspace is mutated. | ||
| async function evalToolFixture(toolName: string, input: unknown): Promise<unknown> { | ||
| if (toolName === "workspace_read_items") { | ||
| const fixture = await getEvalStandupFixture(); | ||
| const requests = ( | ||
| input as { | ||
| requests?: Array<{ editRef?: string; mode?: string; path?: string }>; | ||
| } | ||
| )?.requests; | ||
| const results = (requests ?? []).map((request) => { | ||
| if (request.path !== STANDUP_PATH) { | ||
| return { code: "path_not_found", path: request.path ?? "", status: "failed" }; | ||
| } | ||
| if (request.mode === "block") { | ||
| const editRef = request.editRef ?? ""; | ||
| const content = fixture.blocks.get(editRef); | ||
| if (!content) { | ||
| return { code: "edit_ref_not_found", path: STANDUP_PATH, status: "failed" }; | ||
| } | ||
|
|
||
| return { | ||
| content, | ||
| editRef, | ||
| format: "html", | ||
| itemId: "standup-document", | ||
| path: STANDUP_PATH, | ||
| status: "ready", | ||
| type: "block", | ||
| }; | ||
| } | ||
| if (request.mode !== "start") { | ||
| return { code: "invalid_selection", path: STANDUP_PATH, status: "failed" }; | ||
| } | ||
|
|
||
| return { | ||
| content: fixture.content, | ||
| format: "html", | ||
| itemId: "standup-document", | ||
| location: { endBlock: 2, kind: "blocks", startBlock: 1, totalBlocks: 2 }, | ||
| path: STANDUP_PATH, | ||
| status: "ready", | ||
| type: "document", | ||
| }; | ||
| }); | ||
|
|
||
| return { references: [], results }; | ||
| } | ||
| return { ok: true, note: "eval stub — no real mutation" }; | ||
| } | ||
|
|
||
| // Real workspace tools with stubbed execution. Evals grade tool *selection* and | ||
| // *argument validity*, so the model must see the SAME surface production sends: | ||
| // the provider-compatible schema (maxItems stripped, which Anthropic requires) via | ||
| // the shared `createProviderCompatibleInputSchema`, plus the `inputExamples` the | ||
| // gateway middleware injects. Only execution is stubbed. | ||
| function buildEvalToolSet(): ToolSet { | ||
| function buildEvalToolSet(canMutate: boolean): ToolSet { | ||
| return Object.fromEntries( | ||
| workspaceToolDefinitions.map((definition) => [ | ||
| definition.name, | ||
| tool({ | ||
| description: definition.description, | ||
| inputSchema: createProviderCompatibleInputSchema( | ||
| asSchema(definition.inputSchema as z.ZodTypeAny), | ||
| ), | ||
| inputExamples: definition.inputExamples, | ||
| execute: async () => evalToolFixture(definition.name), | ||
| }), | ||
| ]), | ||
| workspaceToolDefinitions | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · This loops over your list twice because .filter().map() makes two passes, so do it in one pass with .reduce() or a for...of loop Fix → Combine |
||
| .filter((definition) => canMutate || definition.access === "read") | ||
| .map((definition) => [ | ||
| definition.name, | ||
| tool({ | ||
| description: definition.description, | ||
| inputSchema: createProviderCompatibleInputSchema( | ||
| asSchema(definition.inputSchema as z.ZodTypeAny), | ||
| ), | ||
| inputExamples: definition.inputExamples, | ||
| execute: async (input: unknown) => evalToolFixture(definition.name, input), | ||
| }), | ||
| ]), | ||
| ) as ToolSet; | ||
| } | ||
|
|
||
| const EVAL_TOOL_SET = buildEvalToolSet(); | ||
|
|
||
| /** | ||
| * Run one workspace-agent turn against a real model and return a normalized, | ||
| * gradeable result. Invalid tool calls are captured (the AI SDK surfaces them as | ||
| * content parts rather than throwing), then re-validated against the real schema. | ||
| */ | ||
| export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise<WorkspaceAgentOutput> { | ||
| const canMutate = input.canMutate ?? true; | ||
| const modelId = resolveWorkspaceAiChatModelId( | ||
| input.modelId ?? DEFAULT_WORKSPACE_AI_CHAT_MODEL_ID, | ||
| ); | ||
| const system = input.system | ||
| ? `${getAIThreadSoulPrompt()}\n\n${input.system}` | ||
| : getAIThreadSoulPrompt(); | ||
| // Production-identical system text: the soul prompt, the workspace citation | ||
| // rules, and the runtime scope block that `beforeTurn` injects. Grading a | ||
| // model against a thinner prompt than production ships would measure the | ||
| // harness, not the product. | ||
| const workspacePrompt = getAIThreadSystemPromptForWorkspace( | ||
| getAIThreadSoulPrompt(), | ||
| { canMutate, workspaceName: "Study" }, | ||
| { timeZone: "America/New_York" }, | ||
| ); | ||
|
|
||
| const result = await generateText({ | ||
| model: getWorkspaceAiLanguageModel(modelId, env, "eval"), | ||
| providerOptions: getWorkspaceAiGatewayProviderOptions({ modelId }), | ||
| system, | ||
| system: workspacePrompt, | ||
| prompt: input.prompt, | ||
| tools: EVAL_TOOL_SET, | ||
| tools: buildEvalToolSet(canMutate), | ||
| // A couple of steps so read→write flows can happen; kept small and cheap. | ||
| stopWhen: stepCountIs(3), | ||
| }); | ||
|
|
||
| const toolCalls: WorkspaceAgentToolCall[] = []; | ||
| const priorReadEditRefsByPath = new Map<string, Set<string>>(); | ||
| for (const step of result.steps) { | ||
| for (const part of step.content) { | ||
| if (part.type !== "tool-call") continue; | ||
| const definition = getWorkspaceToolDefinition(part.toolName); | ||
| const parsed = definition ? definition.inputSchema.safeParse(part.input) : null; | ||
| toolCalls.push({ | ||
| name: part.toolName, | ||
| known: Boolean(definition), | ||
| input: part.input, | ||
| priorReadEditRefsByPath: Object.fromEntries( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A schema-valid edit path such as Prompt for AI agents |
||
| [...priorReadEditRefsByPath].map(([path, refs]) => [path, [...refs]]), | ||
| ), | ||
| valid: parsed ? parsed.success : false, | ||
| issues: parsed | ||
| ? parsed.success | ||
|
|
@@ -137,7 +213,49 @@ export async function runWorkspaceAgent(input: WorkspaceAgentInput): Promise<Wor | |
| : ["unknown tool"], | ||
| }); | ||
| } | ||
| for (const toolResult of step.toolResults) { | ||
| if (toolResult.toolName === "workspace_read_items") { | ||
| collectReadEditRefs(toolResult.output, priorReadEditRefsByPath); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { text: result.text, toolCalls }; | ||
| } | ||
|
|
||
| function collectReadEditRefs(output: unknown, refsByPath: Map<string, Set<string>>) { | ||
| const results = (output as { results?: unknown[] })?.results; | ||
| if (!Array.isArray(results)) { | ||
| return; | ||
| } | ||
|
|
||
| return { text: result.text, finishReason: result.finishReason, toolCalls }; | ||
| for (const result of results) { | ||
| if (!result || typeof result !== "object") { | ||
| continue; | ||
| } | ||
|
|
||
| const { content, editRef, path } = result as { | ||
| content?: unknown; | ||
| editRef?: unknown; | ||
| path?: unknown; | ||
| }; | ||
| if (typeof path !== "string") { | ||
| continue; | ||
| } | ||
| let refs = refsByPath.get(path); | ||
| if (!refs) { | ||
| refs = new Set<string>(); | ||
| refsByPath.set(path, refs); | ||
| } | ||
| if (typeof editRef === "string") { | ||
| refs.add(editRef); | ||
| } | ||
| if (typeof content === "string") { | ||
| for (const match of content.matchAll(/data-edit-ref="([^"]+)"/g)) { | ||
| if (match[1]) { | ||
| refs.add(match[1]); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.