Unified · Split
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; |
| 2 | +import { Effect } from "effect"; | |
| 2 | 3 | import { describe, expect, it } from "vitest"; |
| 3 | 4 | import { runAgentLoop } from "./agent-loop.js"; |
| 4 | 5 | |
| @@ -6,27 +7,35 @@ describe("runAgentLoop", () => { · 1 unmodified line | ||
| 6 | 7 | it("tracks three code runs when claude tool loop executes run_code three times", async () => { |
| 7 | 8 | let runCount = 0; |
| 8 | 9 | |
| 9 | − const result = await runAgentLoop( | |
| 10 | − "please schedule dinner, lunch, and breakfast", | |
| 11 | − async (_code) => { | |
| 12 | − runCount += 1; | |
| 13 | − return succeededRun(runCount); | |
| 14 | − }, | |
| 15 | − { | |
| 16 | − runWithClaude: async ({ executeCode }) => { | |
| 17 | − await executeCode({ code: "await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });" }); | |
| 18 | − await executeCode({ code: "await tools.calendar.update({ title: 'Lunch', startsAt: '2026-02-08T14:00:00.000Z' });" }); | |
| 19 | − await executeCode({ code: "await tools.calendar.update({ title: 'Breakfast', startsAt: '2026-02-09T03:00:00.000Z' });" }); | |
| 20 | − return { | |
| 21 | − text: "Added 3 calendar events.", | |
| 22 | − modelID: "claude-3-7-sonnet-latest", | |
| 23 | − authSource: "test", | |
| 24 | − }; | |
| 10 | + const result = await Effect.runPromise( | |
| 11 | + runAgentLoop({ | |
| 12 | + prompt: "please schedule dinner, lunch, and breakfast", | |
| 13 | + now: new Date("2026-02-06T10:00:00.000Z"), | |
| 14 | + runCode: (_code) => { | |
| 15 | + runCount += 1; | |
| 16 | + return Effect.succeed(succeededRun(runCount)); | |
| 25 | 17 | }, |
| 26 | − }, | |
| 18 | + runPlanner: ({ executeCode }) => | |
| 19 | + Effect.gen(function* () { | |
| 20 | + yield* executeCode({ | |
| 21 | + code: "await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });", | |
| 22 | + }); | |
| 23 | + yield* executeCode({ | |
| 24 | + code: "await tools.calendar.update({ title: 'Lunch', startsAt: '2026-02-08T14:00:00.000Z' });", | |
| 25 | + }); | |
| 26 | + yield* executeCode({ | |
| 27 | + code: "await tools.calendar.update({ title: 'Breakfast', startsAt: '2026-02-09T03:00:00.000Z' });", | |
| 28 | + }); | |
| 29 | + return { | |
| 30 | + plannerName: "test", | |
| 31 | + text: "Added 3 calendar events.", | |
| 32 | + modelID: "claude-3-7-sonnet-latest", | |
| 33 | + authSource: "test", | |
| 34 | + }; | |
| 35 | + }), | |
| 36 | + }), | |
| 27 | 37 | ); |
| 28 | 38 | |
| 29 | − expect(result.provider).toBe("claude"); | |
| 30 | 39 | expect(result.text).toContain("3 calendar events"); |
| 31 | 40 | expect(result.runs).toHaveLength(3); |
| 32 | 41 | expect(runCount).toBe(3); |
| @@ -37,27 +46,30 @@ describe("runAgentLoop", () => { · 4 unmodified lines | ||
| 37 | 46 | it("rejects invalid generated tool code via typecheck before execution", async () => { |
| 38 | 47 | let runCount = 0; |
| 39 | 48 | |
| 40 | − const result = await runAgentLoop( | |
| 41 | − "add dinner event", | |
| 42 | − async (_code) => { | |
| 43 | − runCount += 1; | |
| 44 | − return succeededRun(runCount); | |
| 45 | − }, | |
| 46 | − { | |
| 47 | − runWithClaude: async ({ executeCode }) => { | |
| 48 | − await executeCode({ | |
| 49 | − code: "await tools.calendar.create({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });", | |
| 50 | − }); | |
| 51 | − await executeCode({ | |
| 52 | − code: "await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });", | |
| 53 | − }); | |
| 54 | − return { | |
| 55 | − text: "done", | |
| 56 | − modelID: "claude-3-7-sonnet-latest", | |
| 57 | − authSource: "test", | |
| 58 | − }; | |
| 49 | + const result = await Effect.runPromise( | |
| 50 | + runAgentLoop({ | |
| 51 | + prompt: "add dinner event", | |
| 52 | + now: new Date("2026-02-06T10:00:00.000Z"), | |
| 53 | + runCode: (_code) => { | |
| 54 | + runCount += 1; | |
| 55 | + return Effect.succeed(succeededRun(runCount)); | |
| 59 | 56 | }, |
| 60 | − }, | |
| 57 | + runPlanner: ({ executeCode }) => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + yield* executeCode({ | |
| 60 | + code: "await tools.calendar.create({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });", | |
| 61 | + }); | |
| 62 | + yield* executeCode({ | |
| 63 | + code: "await tools.calendar.update({ title: 'Dinner', startsAt: '2026-02-07T17:00:00.000Z' });", | |
| 64 | + }); | |
| 65 | + return { | |
| 66 | + plannerName: "test", | |
| 67 | + text: "done", | |
| 68 | + modelID: "claude-3-7-sonnet-latest", | |
| 69 | + authSource: "test", | |
| 70 | + }; | |
| 71 | + }), | |
| 72 | + }), | |
| 61 | 73 | ); |
| 62 | 74 | |
| 63 | 75 | expect(runCount).toBe(1); |
| @@ -73,45 +85,44 @@ describe("runAgentLoop", () => { · 9 unmodified lines | ||
| 73 | 85 | it("returns a failure response when claude generation fails", async () => { |
| 74 | 86 | const codeRuns: string[] = []; |
| 75 | 87 | |
| 76 | − const result = await runAgentLoop( | |
| 77 | − "update my calendar to put dinner with ella at 5 pm", | |
| 78 | − async (code) => { | |
| 79 | − codeRuns.push(code); | |
| 80 | − return succeededRun(1); | |
| 81 | − }, | |
| 82 | − { | |
| 83 | − runWithClaude: async () => { | |
| 84 | − throw new Error("claude unavailable"); | |
| 85 | − }, | |
| 86 | − }, | |
| 87 | − ); | |
| 88 | + await expect( | |
| 89 | + Effect.runPromise( | |
| 90 | + runAgentLoop({ | |
| 91 | + prompt: "update my calendar to put dinner with ella at 5 pm", | |
| 92 | + now: new Date("2026-02-06T10:00:00.000Z"), | |
| 93 | + runCode: (code) => { | |
| 94 | + codeRuns.push(code); | |
| 95 | + return Effect.succeed(succeededRun(1)); | |
| 96 | + }, | |
| 97 | + runPlanner: () => Effect.fail(new Error("claude unavailable")), | |
| 98 | + }), | |
| 99 | + ), | |
| 100 | + ).rejects.toThrow("claude unavailable"); | |
| 88 | 101 | |
| 89 | − expect(result.provider).toBe("claude"); | |
| 90 | − expect(result.planner).toContain("failed before completion"); | |
| 91 | − expect(result.text).toContain("claude unavailable"); | |
| 92 | − expect(result.runs).toHaveLength(0); | |
| 93 | 102 | expect(codeRuns).toHaveLength(0); |
| 94 | 103 | }); |
| 95 | 104 | |
| 96 | 105 | it("allows a zero-tool response when claude does not call run_code", async () => { |
| 97 | 106 | const codeRuns: string[] = []; |
| 98 | 107 | |
| 99 | − const result = await runAgentLoop( | |
| 100 | − "Please add dinner at 5pm tomorrow to my calendar", | |
| 101 | − async (code) => { | |
| 102 | − codeRuns.push(code); | |
| 103 | − return succeededRun(1); | |
| 104 | − }, | |
| 105 | − { | |
| 106 | − runWithClaude: async () => ({ | |
| 107 | − text: "I added it.", | |
| 108 | − modelID: "claude-3-7-sonnet-latest", | |
| 109 | − authSource: "test", | |
| 110 | − }), | |
| 111 | − }, | |
| 108 | + const result = await Effect.runPromise( | |
| 109 | + runAgentLoop({ | |
| 110 | + prompt: "Please add dinner at 5pm tomorrow to my calendar", | |
| 111 | + now: new Date("2026-02-06T10:00:00.000Z"), | |
| 112 | + runCode: (code) => { | |
| 113 | + codeRuns.push(code); | |
| 114 | + return Effect.succeed(succeededRun(1)); | |
| 115 | + }, | |
| 116 | + runPlanner: () => | |
| 117 | + Effect.succeed({ | |
| 118 | + plannerName: "test", | |
| 119 | + text: "I added it.", | |
| 120 | + modelID: "claude-3-7-sonnet-latest", | |
| 121 | + authSource: "test", | |
| 122 | + }), | |
| 123 | + }), | |
| 112 | 124 | ); |
| 113 | 125 | |
| 114 | − expect(result.provider).toBe("claude"); | |
| 115 | 126 | expect(result.runs).toHaveLength(0); |
| 116 | 127 | expect(codeRuns).toHaveLength(0); |
| 117 | 128 | expect(result.planner).toContain("0 code runs"); |
| @@ -1,73 +1,68 @@ | ||
| 1 | 1 | import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; |
| 2 | 2 | import { generateText, stepCountIs, tool } from "ai"; |
| 3 | +import { Effect } from "effect"; | |
| 3 | 4 | import { getAnthropicModel } from "./anthropic-provider.js"; |
| 4 | 5 | import { typecheckCodeSnippet } from "./code-typecheck.js"; |
| 5 | 6 | import { z } from "zod"; |
| 6 | 7 | |
| 7 | −export interface AgentCodeRun { | |
| 8 | +const RUN_CODE_TOOL_SCHEMA = z.object({ | |
| 9 | + code: z.string().min(1), | |
| 10 | +}); | |
| 11 | + | |
| 12 | +const DEFAULT_ANTHROPIC_MODEL = | |
| 13 | + Bun.env.OPENASSISTANT_ANTHROPIC_MODEL?.trim() ?? | |
| 14 | + Bun.env.OPENASSISTANT_CLAUDE_MODEL?.trim() ?? | |
| 15 | + "claude-opus-4-5-20251101"; | |
| 16 | +const CLAUDE_TIMEOUT_MS = Number(Bun.env.OPENASSISTANT_CLAUDE_TIMEOUT_MS ?? 60_000); | |
| 17 | +const CLAUDE_MAX_STEPS = Number(Bun.env.OPENASSISTANT_AGENT_MAX_STEPS ?? 8); | |
| 18 | + | |
| 19 | +export type AgentCodeRun = { | |
| 8 | 20 | code: string; |
| 9 | 21 | result: CodeModeRunResult; |
| 10 | − reason?: string | undefined; | |
| 11 | −} | |
| 22 | +}; | |
| 12 | 23 | |
| 13 | −export interface AgentLoopResult { | |
| 14 | − provider: "claude"; | |
| 24 | +export type AgentLoopResult = { | |
| 15 | 25 | planner: string; |
| 16 | 26 | text: string; |
| 17 | 27 | runs: AgentCodeRun[]; |
| 18 | −} | |
| 28 | +}; | |
| 19 | 29 | |
| 20 | −interface RunCodeToolInput { | |
| 21 | − code: string; | |
| 22 | − reason?: string | undefined; | |
| 23 | −} | |
| 30 | +type RunCodeToolInput = z.infer<typeof RUN_CODE_TOOL_SCHEMA>; | |
| 24 | 31 | |
| 25 | −interface RunCodeToolOutput { | |
| 32 | +type RunCodeToolOutput = { | |
| 26 | 33 | ok: boolean; |
| 27 | 34 | receipts: ToolCallReceipt[]; |
| 28 | 35 | value?: unknown; |
| 29 | 36 | error?: string; |
| 30 | −} | |
| 37 | +}; | |
| 31 | 38 | |
| 32 | −interface RunWithClaudeInput { | |
| 39 | +type PlannerInput = { | |
| 33 | 40 | prompt: string; |
| 34 | 41 | now: Date; |
| 35 | − executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>; | |
| 36 | −} | |
| 42 | + executeCode: (input: RunCodeToolInput) => Effect.Effect<RunCodeToolOutput>; | |
| 43 | +}; | |
| 37 | 44 | |
| 38 | −interface RunWithClaudeOutput { | |
| 45 | +type PlannerOutput = { | |
| 46 | + plannerName: string; | |
| 39 | 47 | text: string; |
| 40 | 48 | modelID: string; |
| 41 | 49 | authSource: string; |
| 42 | −} | |
| 43 | − | |
| 44 | −interface AgentLoopOptions { | |
| 45 | − now?: Date; | |
| 46 | − runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>; | |
| 47 | −} | |
| 50 | +}; | |
| 48 | 51 | |
| 49 | −const DEFAULT_ANTHROPIC_MODEL = | |
| 50 | − readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ?? | |
| 51 | − readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ?? | |
| 52 | − "claude-opus-4-5-20251101"; | |
| 53 | −const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000); | |
| 54 | −const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8); | |
| 52 | +type RunPlanner = (input: PlannerInput) => Effect.Effect<PlannerOutput, unknown>; | |
| 55 | 53 | |
| 56 | −const RUN_CODE_TOOL_SCHEMA = z.object({ | |
| 57 | − code: z.string().min(1), | |
| 58 | − reason: z.string().min(1).optional(), | |
| 59 | −}); | |
| 54 | +export type RunAgentLoopInput = { | |
| 55 | + prompt: string; | |
| 56 | + runCode: (code: string) => Effect.Effect<CodeModeRunResult>; | |
| 57 | + now: Date; | |
| 58 | + runPlanner: RunPlanner; | |
| 59 | +}; | |
| 60 | 60 | |
| 61 | −export async function runAgentLoop( | |
| 62 | − prompt: string, | |
| 63 | − runCode: (code: string) => Promise<CodeModeRunResult>, | |
| 64 | − options: AgentLoopOptions = {}, | |
| 65 | −): Promise<AgentLoopResult> { | |
| 66 | − const now = options.now ?? new Date(); | |
| 61 | +export const runAgentLoop = Effect.fn("AgentLoop.run")(function* (input: RunAgentLoopInput) { | |
| 67 | 62 | const runs: AgentCodeRun[] = []; |
| 68 | 63 | |
| 69 | − const executeCode = async (input: RunCodeToolInput): Promise<RunCodeToolOutput> => { | |
| 70 | − const typecheck = typecheckCodeSnippet(input.code); | |
| 64 | + const executeCode = Effect.fn("AgentLoop.executeCode")(function* (toolInput: RunCodeToolInput) { | |
| 65 | + const typecheck = typecheckCodeSnippet(toolInput.code); | |
| 71 | 66 | if (!typecheck.ok) { |
| 72 | 67 | const failed: CodeModeRunResult = { |
| 73 | 68 | ok: false, |
| @@ -75,100 +70,81 @@ export async function runAgentLoop( · 1 unmodified line | ||
| 75 | 70 | receipts: [], |
| 76 | 71 | }; |
| 77 | 72 | runs.push({ |
| 78 | − code: input.code, | |
| 73 | + code: toolInput.code, | |
| 79 | 74 | result: failed, |
| 80 | − ...(input.reason ? { reason: input.reason } : {}), | |
| 81 | 75 | }); |
| 82 | − return { | |
| 83 | − ok: false, | |
| 84 | − error: failed.error, | |
| 85 | − receipts: [], | |
| 86 | − }; | |
| 76 | + return toRunCodeToolOutput(failed); | |
| 87 | 77 | } |
| 88 | 78 | |
| 89 | − const result = await runCode(input.code); | |
| 79 | + const result = yield* input.runCode(toolInput.code); | |
| 90 | 80 | runs.push({ |
| 91 | − code: input.code, | |
| 81 | + code: toolInput.code, | |
| 92 | 82 | result, |
| 93 | − ...(input.reason ? { reason: input.reason } : {}), | |
| 94 | 83 | }); |
| 95 | − | |
| 96 | − if (result.ok) { | |
| 97 | − return { | |
| 98 | − ok: true, | |
| 99 | − value: result.value, | |
| 100 | − receipts: result.receipts, | |
| 101 | − }; | |
| 102 | − } | |
| 103 | − | |
| 104 | − return { | |
| 105 | − ok: false, | |
| 106 | − error: result.error, | |
| 107 | − receipts: result.receipts, | |
| 108 | − }; | |
| 84 | + return toRunCodeToolOutput(result); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + const generated = yield* input.runPlanner({ | |
| 88 | + prompt: input.prompt, | |
| 89 | + now: input.now, | |
| 90 | + executeCode, | |
| 91 | + }); | |
| 92 | + return { | |
| 93 | + planner: `${generated.plannerName} tool-loop (${runs.length} code runs, model=${generated.modelID}, auth=${generated.authSource}).`, | |
| 94 | + text: generated.text, | |
| 95 | + runs, | |
| 109 | 96 | }; |
| 97 | +}); | |
| 110 | 98 | |
| 111 | − try { | |
| 112 | − const generated = await (options.runWithClaude ?? runWithClaude)({ | |
| 113 | − prompt, | |
| 114 | − now, | |
| 115 | − executeCode, | |
| 116 | − }); | |
| 99 | +export type RunAgentLoopWithAnthropicInput = Omit<RunAgentLoopInput, "runPlanner">; | |
| 117 | 100 | |
| 118 | − return { | |
| 119 | − provider: "claude", | |
| 120 | − planner: `Claude tool-loop (${runs.length} code run${runs.length === 1 ? "" : "s"}, model=${generated.modelID}, auth=${generated.authSource}).`, | |
| 121 | − text: generated.text, | |
| 122 | − runs, | |
| 123 | − }; | |
| 124 | − } catch (error) { | |
| 125 | − return { | |
| 126 | − provider: "claude", | |
| 127 | − planner: `Claude tool-loop failed before completion (model=${DEFAULT_ANTHROPIC_MODEL}).`, | |
| 128 | − text: `Agent loop failed: ${describeUnknown(error)}. No actions were executed.`, | |
| 129 | − runs, | |
| 130 | − }; | |
| 131 | − } | |
| 132 | −} | |
| 101 | +export const runAgentLoopWithAnthropic = Effect.fn("AgentLoop.runWithAnthropic")(function* ( | |
| 102 | + input: RunAgentLoopWithAnthropicInput, | |
| 103 | +) { | |
| 104 | + return yield* runAgentLoop({ | |
| 105 | + ...input, | |
| 106 | + runPlanner: runAnthropicPlanner, | |
| 107 | + }); | |
| 108 | +}); | |
| 133 | 109 | |
| 134 | −async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> { | |
| 110 | +const runAnthropicPlanner = Effect.fn("AgentLoop.runAnthropicPlanner")(function* (input: PlannerInput) { | |
| 135 | 111 | const abortController = new AbortController(); |
| 136 | 112 | const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS); |
| 137 | 113 | |
| 138 | − try { | |
| 139 | − const { model, authSource, authMode } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | |
| 140 | − const result = await generateText({ | |
| 141 | − model, | |
| 142 | − temperature: 0, | |
| 143 | − stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | |
| 144 | − prompt: buildAgentPrompt(input.prompt, input.now), | |
| 145 | − system: buildSystemPrompt(authMode), | |
| 146 | − tools: { | |
| 147 | − run_code: tool({ | |
| 148 | − description: | |
| 149 | − "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | |
| 150 | − inputSchema: RUN_CODE_TOOL_SCHEMA, | |
| 151 | − execute: (args) => input.executeCode(args), | |
| 152 | − }), | |
| 153 | − }, | |
| 154 | − abortSignal: abortController.signal, | |
| 155 | − }); | |
| 156 | − | |
| 157 | − return { | |
| 158 | − text: result.text.trim() || "Done.", | |
| 159 | − modelID: DEFAULT_ANTHROPIC_MODEL, | |
| 160 | − authSource, | |
| 161 | − }; | |
| 162 | − } finally { | |
| 163 | − clearTimeout(timeout); | |
| 164 | − } | |
| 165 | −} | |
| 114 | + const generated = yield* Effect.tryPromise({ | |
| 115 | + try: async () => { | |
| 116 | + const { model, authSource } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | |
| 117 | + const result = await generateText({ | |
| 118 | + model, | |
| 119 | + temperature: 0, | |
| 120 | + stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | |
| 121 | + prompt: buildAgentPrompt(input.prompt, input.now), | |
| 122 | + system: buildSystemPrompt(), | |
| 123 | + tools: { | |
| 124 | + run_code: tool({ | |
| 125 | + description: | |
| 126 | + "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | |
| 127 | + inputSchema: RUN_CODE_TOOL_SCHEMA, | |
| 128 | + execute: (args) => Effect.runPromise(input.executeCode(args)), | |
| 129 | + }), | |
| 130 | + }, | |
| 131 | + abortSignal: abortController.signal, | |
| 132 | + }); | |
| 166 | 133 | |
| 167 | −function buildSystemPrompt(authMode: "api" | "oauth"): string { | |
| 168 | − if (authMode === "oauth") { | |
| 169 | − return "You are Claude Code, Anthropic's official CLI for Claude."; | |
| 170 | − } | |
| 134 | + return { | |
| 135 | + plannerName: "Anthropic Claude", | |
| 136 | + text: result.text.trim() || "Done.", | |
| 137 | + modelID: DEFAULT_ANTHROPIC_MODEL, | |
| 138 | + authSource, | |
| 139 | + } as PlannerOutput; | |
| 140 | + }, | |
| 141 | + catch: (error) => error, | |
| 142 | + }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timeout)))); | |
| 143 | + | |
| 144 | + return generated; | |
| 145 | +}); | |
| 171 | 146 | |
| 147 | +function buildSystemPrompt(): string { | |
| 172 | 148 | return [ |
| 173 | 149 | "You are OpenAssistant. Execute work via the run_code tool and then report what happened.", |
| 174 | 150 | "In your final response, summarize relevant tool activity and mention failures/denials clearly.", |
| @@ -188,14 +164,18 @@ function buildAgentPrompt(userPrompt: string, now: Date): string { · 13 unmodified lines | ||
| 188 | 164 | ].join("\n"); |
| 189 | 165 | } |
| 190 | 166 | |
| 191 | −function describeUnknown(error: unknown): string { | |
| 192 | − if (error instanceof Error) { | |
| 193 | − return error.message; | |
| 167 | +function toRunCodeToolOutput(result: CodeModeRunResult): RunCodeToolOutput { | |
| 168 | + if (result.ok) { | |
| 169 | + return { | |
| 170 | + ok: true, | |
| 171 | + value: result.value, | |
| 172 | + receipts: result.receipts, | |
| 173 | + }; | |
| 194 | 174 | } |
| 195 | − return String(error); | |
| 196 | −} | |
| 197 | 175 | |
| 198 | −function readEnv(key: string): string | undefined { | |
| 199 | − const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 200 | − return bun?.env?.[key] ?? process.env[key]; | |
| 176 | + return { | |
| 177 | + ok: false, | |
| 178 | + error: result.error, | |
| 179 | + receipts: result.receipts, | |
| 180 | + }; | |
| 201 | 181 | } |
| @@ -1,77 +1,71 @@ | ||
| 1 | 1 | import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; |
| 2 | 2 | import { generateText, stepCountIs, tool } from "ai"; |
| 3 | +import { Effect } from "effect"; | |
| 3 | 4 | import { getAnthropicModel } from "./anthropic-provider.js"; |
| 4 | 5 | import { typecheckCodeSnippet } from "./code-typecheck.js"; |
| 5 | 6 | import { z } from "zod"; |
| 6 | 7 | |
| 7 | −export interface AgentCodeRun { | |
| 8 | +const RUN_CODE_TOOL_SCHEMA = z.object({ | |
| 9 | + code: z.string().min(1), | |
| 10 | +}); | |
| 11 | + | |
| 12 | +const DEFAULT_ANTHROPIC_MODEL = | |
| 13 | + Bun.env.OPENASSISTANT_ANTHROPIC_MODEL?.trim() ?? | |
| 14 | + Bun.env.OPENASSISTANT_CLAUDE_MODEL?.trim() ?? | |
| 15 | + "claude-opus-4-5-20251101"; | |
| 16 | +const CLAUDE_TIMEOUT_MS = Number(Bun.env.OPENASSISTANT_CLAUDE_TIMEOUT_MS ?? 60_000); | |
| 17 | +const CLAUDE_MAX_STEPS = Number(Bun.env.OPENASSISTANT_AGENT_MAX_STEPS ?? 8); | |
| 18 | + | |
| 19 | +export type AgentCodeRun = { | |
| 8 | 20 | code: string; |
| 9 | 21 | result: CodeModeRunResult; |
| 10 | − reason?: string | undefined; | |
| 11 | −} | |
| 22 | +}; | |
| 12 | 23 | |
| 13 | −export interface AgentLoopResult { | |
| 14 | − provider: "claude"; | |
| 24 | +export type AgentLoopResult = { | |
| 15 | 25 | planner: string; |
| 16 | 26 | text: string; |
| 17 | 27 | runs: AgentCodeRun[]; |
| 18 | −} | |
| 28 | +}; | |
| 19 | 29 | |
| 20 | −interface RunCodeToolInput { | |
| 21 | − code: string; | |
| 22 | − reason?: string | undefined; | |
| 23 | −} | |
| 30 | +type RunCodeToolInput = z.infer<typeof RUN_CODE_TOOL_SCHEMA>; | |
| 24 | 31 | |
| 25 | −interface RunCodeToolOutput { | |
| 32 | +type RunCodeToolOutput = { | |
| 26 | 33 | ok: boolean; |
| 27 | 34 | receipts: ToolCallReceipt[]; |
| 28 | 35 | value?: unknown; |
| 29 | 36 | error?: string; |
| 30 | −} | |
| 37 | +}; | |
| 31 | 38 | |
| 32 | −interface RunWithClaudeInput { | |
| 39 | +type PlannerInput = { | |
| 33 | 40 | prompt: string; |
| 34 | 41 | now: Date; |
| 35 | − toolPromptGuidance?: string; | |
| 36 | − executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>; | |
| 37 | −} | |
| 42 | + toolPromptGuidance: string; | |
| 43 | + executeCode: (input: RunCodeToolInput) => Effect.Effect<RunCodeToolOutput>; | |
| 44 | +}; | |
| 38 | 45 | |
| 39 | −interface RunWithClaudeOutput { | |
| 46 | +type PlannerOutput = { | |
| 47 | + plannerName: string; | |
| 40 | 48 | text: string; |
| 41 | 49 | modelID: string; |
| 42 | 50 | authSource: string; |
| 43 | −} | |
| 51 | +}; | |
| 44 | 52 | |
| 45 | −interface AgentLoopOptions { | |
| 46 | − now?: Date; | |
| 47 | − toolPromptGuidance?: string; | |
| 48 | − toolTypeDeclarations?: string; | |
| 49 | − runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>; | |
| 50 | −} | |
| 53 | +type RunPlanner = (input: PlannerInput) => Effect.Effect<PlannerOutput, unknown>; | |
| 51 | 54 | |
| 52 | −const DEFAULT_ANTHROPIC_MODEL = | |
| 53 | − readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ?? | |
| 54 | − readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ?? | |
| 55 | − "claude-opus-4-5-20251101"; | |
| 56 | −const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000); | |
| 57 | −const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8); | |
| 58 | − | |
| 59 | −const RUN_CODE_TOOL_SCHEMA = z.object({ | |
| 60 | − code: z.string().min(1), | |
| 61 | − reason: z.string().min(1).optional(), | |
| 62 | −}); | |
| 55 | +export type RunAgentLoopInput = { | |
| 56 | + prompt: string; | |
| 57 | + runCode: (code: string) => Effect.Effect<CodeModeRunResult>; | |
| 58 | + now: Date; | |
| 59 | + toolPromptGuidance: string; | |
| 60 | + toolTypeDeclarations?: string; | |
| 61 | + runPlanner: RunPlanner; | |
| 62 | +}; | |
| 63 | 63 | |
| 64 | −export async function runAgentLoop( | |
| 65 | − prompt: string, | |
| 66 | − runCode: (code: string) => Promise<CodeModeRunResult>, | |
| 67 | − options: AgentLoopOptions = {}, | |
| 68 | −): Promise<AgentLoopResult> { | |
| 69 | − const now = options.now ?? new Date(); | |
| 64 | +export const runAgentLoop = Effect.fn("AgentLoop.run")(function* (input: RunAgentLoopInput) { | |
| 70 | 65 | const runs: AgentCodeRun[] = []; |
| 71 | 66 | |
| 72 | − const executeCode = async (input: RunCodeToolInput): Promise<RunCodeToolOutput> => { | |
| 73 | − console.log("executeCode", input.code); | |
| 74 | − const typecheck = typecheckCodeSnippet(input.code, options.toolTypeDeclarations); | |
| 67 | + const executeCode = Effect.fn("AgentLoop.executeCode")(function* (toolInput: RunCodeToolInput) { | |
| 68 | + const typecheck = typecheckCodeSnippet(toolInput.code, input.toolTypeDeclarations); | |
| 75 | 69 | if (!typecheck.ok) { |
| 76 | 70 | const failed: CodeModeRunResult = { |
| 77 | 71 | ok: false, |
| @@ -79,108 +73,89 @@ export async function runAgentLoop( · 1 unmodified line | ||
| 79 | 73 | receipts: [], |
| 80 | 74 | }; |
| 81 | 75 | runs.push({ |
| 82 | − code: input.code, | |
| 76 | + code: toolInput.code, | |
| 83 | 77 | result: failed, |
| 84 | − ...(input.reason ? { reason: input.reason } : {}), | |
| 85 | 78 | }); |
| 86 | − return { | |
| 87 | − ok: false, | |
| 88 | − error: failed.error, | |
| 89 | − receipts: [], | |
| 90 | − }; | |
| 79 | + return toRunCodeToolOutput(failed); | |
| 91 | 80 | } |
| 92 | 81 | |
| 93 | − const result = await runCode(input.code); | |
| 82 | + const result = yield* input.runCode(toolInput.code); | |
| 94 | 83 | runs.push({ |
| 95 | − code: input.code, | |
| 84 | + code: toolInput.code, | |
| 96 | 85 | result, |
| 97 | − ...(input.reason ? { reason: input.reason } : {}), | |
| 98 | 86 | }); |
| 99 | − | |
| 100 | − if (result.ok) { | |
| 101 | − return { | |
| 102 | − ok: true, | |
| 103 | − value: result.value, | |
| 104 | − receipts: result.receipts, | |
| 105 | − }; | |
| 106 | − } | |
| 107 | − | |
| 108 | − return { | |
| 109 | − ok: false, | |
| 110 | − error: result.error, | |
| 111 | − receipts: result.receipts, | |
| 112 | − }; | |
| 87 | + return toRunCodeToolOutput(result); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + const generated = yield* input.runPlanner({ | |
| 91 | + prompt: input.prompt, | |
| 92 | + now: input.now, | |
| 93 | + toolPromptGuidance: input.toolPromptGuidance, | |
| 94 | + executeCode, | |
| 95 | + }); | |
| 96 | + return { | |
| 97 | + planner: `${generated.plannerName} tool-loop (${runs.length} code runs, model=${generated.modelID}, auth=${generated.authSource}).`, | |
| 98 | + text: generated.text, | |
| 99 | + runs, | |
| 113 | 100 | }; |
| 101 | +}); | |
| 114 | 102 | |
| 115 | − try { | |
| 116 | − const generated = await (options.runWithClaude ?? runWithClaude)({ | |
| 117 | − prompt, | |
| 118 | − now, | |
| 119 | − ...(options.toolPromptGuidance ? { toolPromptGuidance: options.toolPromptGuidance } : {}), | |
| 120 | − executeCode, | |
| 121 | − }); | |
| 103 | +export type RunAgentLoopWithAnthropicInput = Omit<RunAgentLoopInput, "runPlanner">; | |
| 122 | 104 | |
| 123 | − return { | |
| 124 | − provider: "claude", | |
| 125 | − planner: `Claude tool-loop (${runs.length} code run${runs.length === 1 ? "" : "s"}, model=${generated.modelID}, auth=${generated.authSource}).`, | |
| 126 | − text: generated.text, | |
| 127 | − runs, | |
| 128 | − }; | |
| 129 | − } catch (error) { | |
| 130 | − return { | |
| 131 | − provider: "claude", | |
| 132 | − planner: `Claude tool-loop failed before completion (model=${DEFAULT_ANTHROPIC_MODEL}).`, | |
| 133 | − text: `Agent loop failed: ${describeUnknown(error)}. No actions were executed.`, | |
| 134 | − runs, | |
| 135 | − }; | |
| 136 | − } | |
| 137 | −} | |
| 105 | +export const runAgentLoopWithAnthropic = Effect.fn("AgentLoop.runWithAnthropic")(function* ( | |
| 106 | + input: RunAgentLoopWithAnthropicInput, | |
| 107 | +) { | |
| 108 | + return yield* runAgentLoop({ | |
| 109 | + ...input, | |
| 110 | + runPlanner: runAnthropicPlanner, | |
| 111 | + }); | |
| 112 | +}); | |
| 138 | 113 | |
| 139 | −async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> { | |
| 114 | +const runAnthropicPlanner = Effect.fn("AgentLoop.runAnthropicPlanner")(function* (input: PlannerInput) { | |
| 140 | 115 | const abortController = new AbortController(); |
| 141 | 116 | const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS); |
| 142 | 117 | |
| 143 | − try { | |
| 144 | − const { model, authSource, authMode } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | |
| 145 | − const result = await generateText({ | |
| 146 | − model, | |
| 147 | − temperature: 0, | |
| 148 | − stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | |
| 149 | − prompt: buildAgentPrompt(input.prompt, input.now, input.toolPromptGuidance), | |
| 150 | − system: buildSystemPrompt(authMode), | |
| 151 | − tools: { | |
| 152 | − run_code: tool({ | |
| 153 | − description: | |
| 154 | − "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | |
| 155 | − inputSchema: RUN_CODE_TOOL_SCHEMA, | |
| 156 | − execute: (args) => input.executeCode(args), | |
| 157 | − }), | |
| 158 | − }, | |
| 159 | − abortSignal: abortController.signal, | |
| 160 | − }); | |
| 161 | − | |
| 162 | − return { | |
| 163 | − text: result.text.trim() || "Done.", | |
| 164 | − modelID: DEFAULT_ANTHROPIC_MODEL, | |
| 165 | − authSource, | |
| 166 | − }; | |
| 167 | − } finally { | |
| 168 | − clearTimeout(timeout); | |
| 169 | − } | |
| 170 | −} | |
| 118 | + const generated = yield* Effect.tryPromise({ | |
| 119 | + try: async () => { | |
| 120 | + const { model, authSource } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | |
| 121 | + const result = await generateText({ | |
| 122 | + model, | |
| 123 | + temperature: 0, | |
| 124 | + stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | |
| 125 | + prompt: buildAgentPrompt(input.prompt, input.now, input.toolPromptGuidance), | |
| 126 | + system: buildSystemPrompt(), | |
| 127 | + tools: { | |
| 128 | + run_code: tool({ | |
| 129 | + description: | |
| 130 | + "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | |
| 131 | + inputSchema: RUN_CODE_TOOL_SCHEMA, | |
| 132 | + execute: (args) => Effect.runPromise(input.executeCode(args)), | |
| 133 | + }), | |
| 134 | + }, | |
| 135 | + abortSignal: abortController.signal, | |
| 136 | + }); | |
| 171 | 137 | |
| 172 | −function buildSystemPrompt(authMode: "api" | "oauth"): string { | |
| 173 | − if (authMode === "oauth") { | |
| 174 | − return "You are Claude Code, Anthropic's official CLI for Claude."; | |
| 175 | − } | |
| 138 | + return { | |
| 139 | + plannerName: "Anthropic Claude", | |
| 140 | + text: result.text.trim() || "Done.", | |
| 141 | + modelID: DEFAULT_ANTHROPIC_MODEL, | |
| 142 | + authSource, | |
| 143 | + } as PlannerOutput; | |
| 144 | + }, | |
| 145 | + catch: (error) => error, | |
| 146 | + }).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timeout)))); | |
| 147 | + | |
| 148 | + return generated; | |
| 149 | +}); | |
| 176 | 150 | |
| 151 | +function buildSystemPrompt(): string { | |
| 177 | 152 | return [ |
| 178 | 153 | "You are OpenAssistant. Execute work via the run_code tool and then report what happened.", |
| 179 | 154 | "In your final response, summarize relevant tool activity and mention failures/denials clearly.", |
| 180 | 155 | ].join("\n"); |
| 181 | 156 | } |
| 182 | 157 | |
| 183 | −function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance?: string): string { | |
| 158 | +function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance: string): string { | |
| 184 | 159 | return [ |
| 185 | 160 | "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", |
| 186 | 161 | "Inside code, call available tools directly using dot notation.", |
| @@ -189,22 +164,24 @@ function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance?: st · 2 unmodified lines | ||
| 189 | 164 | "Only issue another run_code call when the prior run failed or was denied and you are retrying with a fix.", |
| 190 | 165 | "For bulk changes, use read operations first, then write operations one-by-one.", |
| 191 | 166 | "Never claim an action succeeded unless run_code returned ok=true.", |
| 192 | − toolPromptGuidance ? `Available tools:\n${toolPromptGuidance}` : null, | |
| 167 | + `Available tools:\n${toolPromptGuidance}`, | |
| 193 | 168 | `Current timestamp: ${now.toISOString()}`, |
| 194 | 169 | `User request: ${userPrompt}`, |
| 195 | − ] | |
| 196 | − .filter((line): line is string => line !== null) | |
| 197 | − .join("\n"); | |
| 170 | + ].join("\n"); | |
| 198 | 171 | } |
| 199 | 172 | |
| 200 | −function describeUnknown(error: unknown): string { | |
| 201 | − if (error instanceof Error) { | |
| 202 | − return error.message; | |
| 173 | +function toRunCodeToolOutput(result: CodeModeRunResult): RunCodeToolOutput { | |
| 174 | + if (result.ok) { | |
| 175 | + return { | |
| 176 | + ok: true, | |
| 177 | + value: result.value, | |
| 178 | + receipts: result.receipts, | |
| 179 | + }; | |
| 203 | 180 | } |
| 204 | − return String(error); | |
| 205 | −} | |
| 206 | 181 | |
| 207 | −function readEnv(key: string): string | undefined { | |
| 208 | − const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 209 | − return bun?.env?.[key] ?? process.env[key]; | |
| 182 | + return { | |
| 183 | + ok: false, | |
| 184 | + error: result.error, | |
| 185 | + receipts: result.receipts, | |
| 186 | + }; | |
| 210 | 187 | } |
| @@ -7,13 +7,12 @@ import { AgentRpcs, ResolveApprovalOutput, type TurnResult } from "./rpc.js"; · 6 unmodified lines | ||
| 7 | 7 | import { createToolingBundle } from "./tools.js"; |
| 8 | 8 | import { TurnManager } from "./turn-manager.js"; |
| 9 | 9 | |
| 10 | −const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787"); | |
| 10 | +const port = Number(Bun.env.OPENASSISTANT_GATEWAY_PORT ?? "8787"); | |
| 11 | 11 | |
| 12 | 12 | const calendarStore = new InMemoryCalendarStore(); |
| 13 | 13 | const tooling = createToolingBundle(calendarStore); |
| 14 | 14 | const TurnManagerLayer = TurnManager.layer({ |
| 15 | 15 | tools: tooling.tools, |
| 16 | − verboseFooter: isVerboseMode(), | |
| 17 | 16 | toolPromptGuidance: tooling.promptGuidance, |
| 18 | 17 | toolTypeDeclarations: tooling.toolTypeDeclarations, |
| 19 | 18 | formatApproval: tooling.formatApproval, |
| @@ -22,6 +21,18 @@ const TurnManagerLayer = TurnManager.layer({ · 2 unmodified lines | ||
| 22 | 21 | const AgentHandlersLive = AgentRpcs.toLayer( |
| 23 | 22 | Effect.gen(function* () { |
| 24 | 23 | const turnManager = yield* TurnManager; |
| 24 | + const waitForTurnEvent = Effect.fn("Gateway.waitForTurnEvent")(function* (turnId: string) { | |
| 25 | + const event = yield* turnManager.waitForNext(turnId); | |
| 26 | + if (event) { | |
| 27 | + return event; | |
| 28 | + } | |
| 29 | + return { | |
| 30 | + status: "failed", | |
| 31 | + turnId, | |
| 32 | + error: "Turn not found.", | |
| 33 | + } as TurnResult; | |
| 34 | + }); | |
| 35 | + | |
| 25 | 36 | return { |
| 26 | 37 | RunTurn: (input: { prompt: string; requesterId: string; channelId: string; nowIso: string }) => |
| 27 | 38 | Effect.gen(function* () { |
| @@ -31,28 +42,10 @@ const AgentHandlersLive = AgentRpcs.toLayer( · 3 unmodified lines | ||
| 31 | 42 | channelId: input.channelId, |
| 32 | 43 | now: new Date(input.nowIso), |
| 33 | 44 | }); |
| 34 | − const event = yield* turnManager.waitForNext(turnId); | |
| 35 | − if (!event) { | |
| 36 | − return { | |
| 37 | − status: "failed", | |
| 38 | − turnId, | |
| 39 | − error: "Turn not found.", | |
| 40 | − } as TurnResult; | |
| 41 | − } | |
| 42 | − return event; | |
| 45 | + return yield* waitForTurnEvent(turnId); | |
| 43 | 46 | }), |
| 44 | 47 | ContinueTurn: (input: { turnId: string }) => |
| 45 | − Effect.gen(function* () { | |
| 46 | − const event = yield* turnManager.waitForNext(input.turnId); | |
| 47 | − if (!event) { | |
| 48 | − return { | |
| 49 | − status: "failed", | |
| 50 | − turnId: input.turnId, | |
| 51 | − error: "Turn not found.", | |
| 52 | − } as TurnResult; | |
| 53 | − } | |
| 54 | − return event; | |
| 55 | − }), | |
| 48 | + waitForTurnEvent(input.turnId), | |
| 56 | 49 | ResolveApproval: (input: { turnId: string; callId: string; actorId: string; decision: "approved" | "denied" }) => |
| 57 | 50 | Effect.gen(function* () { |
| 58 | 51 | const status = yield* turnManager.resolveApproval({ |
| @@ -81,13 +74,3 @@ const MainLayer = HttpRouter.Default.serve().pipe( · 22 unmodified lines | ||
| 81 | 74 | |
| 82 | 75 | console.log(`[gateway] listening on http://localhost:${port}/rpc`); |
| 83 | 76 | BunRuntime.runMain(Layer.launch(MainLayer)); |
| 84 | − | |
| 85 | −function isVerboseMode(): boolean { | |
| 86 | − const value = readEnv("OPENASSISTANT_VERBOSE_RESPONSE")?.trim().toLowerCase(); | |
| 87 | − return value === "1" || value === "true" || value === "yes"; | |
| 88 | −} | |
| 89 | − | |
| 90 | −function readEnv(key: string): string | undefined { | |
| 91 | − const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | |
| 92 | − return bun?.env?.[key] ?? process.env[key]; | |
| 93 | −} | |
| @@ -5,7 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | type ToolTree, |
| 6 | 6 | } from "@openassistant/core"; |
| 7 | 7 | import type { AgentLoopResult } from "./agent-loop.js"; |
| 8 | −import { runAgentLoop } from "./agent-loop.js"; | |
| 8 | +import { runAgentLoopWithAnthropic } from "./agent-loop.js"; | |
| 9 | 9 | import type { ApprovalPresentation } from "./plugins/plugin-system.js"; |
| 10 | 10 | import { type TurnResult } from "./rpc.js"; |
| 11 | 11 | import { Context, Deferred, Effect, Layer, Ref } from "effect"; |
| @@ -31,14 +31,21 @@ type TurnSession = { · 19 unmodified lines | ||
| 31 | 31 | completed: boolean; |
| 32 | 32 | }; |
| 33 | 33 | |
| 34 | +type TurnManagerParams = { | |
| 35 | + tools: ToolTree; | |
| 36 | + toolPromptGuidance: string; | |
| 37 | + toolTypeDeclarations: string; | |
| 38 | + formatApproval: (request: ApprovalRequest) => ApprovalPresentation; | |
| 39 | +}; | |
| 40 | + | |
| 34 | 41 | export type ResolveApprovalStatus = "resolved" | "not_found" | "unauthorized"; |
| 35 | 42 | |
| 36 | −type WaitForNextState = | |
| 37 | − | { _tag: "missing" } | |
| 38 | − | { _tag: "event"; event: TurnEvent } | |
| 39 | − | { _tag: "await"; deferred: Deferred.Deferred<TurnEvent> }; | |
| 43 | +type WaitForNextResult = | |
| 44 | + | { kind: "missing" } | |
| 45 | + | { kind: "event"; event: TurnEvent } | |
| 46 | + | { kind: "wait"; deferred: Deferred.Deferred<TurnEvent> }; | |
| 40 | 47 | |
| 41 | −type ResolveApprovalState = | |
| 48 | +type ResolveApprovalResult = | |
| 42 | 49 | | { status: "not_found" } |
| 43 | 50 | | { status: "unauthorized" } |
| 44 | 51 | | { |
| @@ -47,42 +54,18 @@ type ResolveApprovalState = · 2 unmodified lines | ||
| 47 | 54 | nextWaiter: Deferred.Deferred<void> | null; |
| 48 | 55 | }; |
| 49 | 56 | |
| 57 | +type TurnManagerService = Effect.Effect.Success<ReturnType<typeof makeTurnManager>>; | |
| 58 | + | |
| 50 | 59 | export class TurnManager extends Context.Tag("@openassistant/gateway/TurnManager")< |
| 51 | 60 | TurnManager, |
| 52 | − { | |
| 53 | − readonly start: (params: { | |
| 54 | − prompt: string; | |
| 55 | − requesterId: string; | |
| 56 | − channelId: string; | |
| 57 | − now: Date; | |
| 58 | − }) => Effect.Effect<string>; | |
| 59 | − readonly waitForNext: (turnId: string) => Effect.Effect<TurnEvent | null>; | |
| 60 | − readonly resolveApproval: (params: { | |
| 61 | − turnId: string; | |
| 62 | − callId: string; | |
| 63 | − actorId: string; | |
| 64 | − decision: ApprovalDecision; | |
| 65 | − }) => Effect.Effect<ResolveApprovalStatus>; | |
| 66 | − } | |
| 61 | + TurnManagerService | |
| 67 | 62 | >() { |
| 68 | − static layer(params: { | |
| 69 | − tools: ToolTree; | |
| 70 | − verboseFooter: boolean; | |
| 71 | − toolPromptGuidance: string; | |
| 72 | − toolTypeDeclarations: string; | |
| 73 | − formatApproval: (request: ApprovalRequest) => ApprovalPresentation; | |
| 74 | − }): Layer.Layer<TurnManager> { | |
| 63 | + static layer(params: TurnManagerParams): Layer.Layer<TurnManager> { | |
| 75 | 64 | return Layer.effect(TurnManager, makeTurnManager(params)); |
| 76 | 65 | } |
| 77 | 66 | } |
| 78 | 67 | |
| 79 | −function makeTurnManager(params: { | |
| 80 | − tools: ToolTree; | |
| 81 | − verboseFooter: boolean; | |
| 82 | − toolPromptGuidance: string; | |
| 83 | − toolTypeDeclarations: string; | |
| 84 | − formatApproval: (request: ApprovalRequest) => ApprovalPresentation; | |
| 85 | −}) { | |
| 68 | +function makeTurnManager(params: TurnManagerParams) { | |
| 86 | 69 | return Effect.gen(function* () { |
| 87 | 70 | const sessionsRef = yield* Ref.make(new Map<string, TurnSession>()); |
| 88 | 71 | |
| @@ -165,71 +148,43 @@ function makeTurnManager(params: { · 76 unmodified lines | ||
| 165 | 148 | |
| 166 | 149 | const formatted = params.formatApproval(request); |
| 167 | 150 | |
| 168 | − yield* emitEvent(turnId, { | |
| 169 | − status: "awaiting_approval", | |
| 170 | − turnId, | |
| 171 | − approval: { | |
| 172 | − callId: request.callId, | |
| 173 | − toolPath: request.toolPath, | |
| 174 | − ...(formatted.title ? { title: formatted.title } : {}), | |
| 175 | − ...(formatted.details ? { details: formatted.details } : {}), | |
| 176 | − ...(formatted.link ? { link: formatted.link } : {}), | |
| 177 | − ...(formatted.inputPreview ? { inputPreview: formatted.inputPreview } : {}), | |
| 178 | − ...(state.currentCode ? { codeSnippet: truncateCode(state.currentCode) } : {}), | |
| 179 | − }, | |
| 180 | − }); | |
| 151 | + yield* emitEvent(turnId, toApprovalEvent(turnId, request, formatted, state.currentCode)); | |
| 181 | 152 | |
| 182 | 153 | return yield* Deferred.await(decision); |
| 183 | 154 | }); |
| 184 | 155 | |
| 156 | + const setCurrentCode = Effect.fn("TurnManager.setCurrentCode")(function* (turnId: string, code: string | null) { | |
| 157 | + yield* Ref.update(sessionsRef, (sessions) => { | |
| 158 | + const session = sessions.get(turnId); | |
| 159 | + if (session) { | |
| 160 | + session.currentCode = code; | |
| 161 | + } | |
| 162 | + return sessions; | |
| 163 | + }); | |
| 164 | + }); | |
| 165 | + | |
| 185 | 166 | const runSession = Effect.fn("TurnManager.runSession")(function* (turnId: string, prompt: string, now: Date) { |
| 186 | 167 | const runner = createCodeModeRunner({ |
| 187 | 168 | tools: params.tools, |
| 188 | 169 | requestApproval: (request) => requestApproval(turnId, request), |
| 189 | 170 | }); |
| 190 | 171 | |
| 191 | − yield* Effect.tryPromise({ | |
| 192 | − try: () => | |
| 193 | − runAgentLoop( | |
| 194 | − prompt, | |
| 195 | − async (code) => { | |
| 196 | − await Effect.runPromise( | |
| 197 | − Ref.update(sessionsRef, (sessions) => { | |
| 198 | − const session = sessions.get(turnId); | |
| 199 | − if (session) { | |
| 200 | − session.currentCode = code; | |
| 201 | − } | |
| 202 | − return sessions; | |
| 203 | − }), | |
| 204 | − ); | |
| 205 | − const result = await Effect.runPromise(runner.run({ code })); | |
| 206 | − await Effect.runPromise( | |
| 207 | − Ref.update(sessionsRef, (sessions) => { | |
| 208 | − const session = sessions.get(turnId); | |
| 209 | − if (session) { | |
| 210 | − session.currentCode = null; | |
| 211 | − } | |
| 212 | − return sessions; | |
| 213 | − }), | |
| 214 | − ); | |
| 215 | − return result; | |
| 216 | − }, | |
| 217 | − { | |
| 218 | − now, | |
| 219 | − toolPromptGuidance: params.toolPromptGuidance, | |
| 220 | − toolTypeDeclarations: params.toolTypeDeclarations, | |
| 221 | − }, | |
| 222 | − ), | |
| 223 | − catch: describeUnknown, | |
| 172 | + const runCode = Effect.fn("TurnManager.runCode")(function* (code: string) { | |
| 173 | + yield* setCurrentCode(turnId, code); | |
| 174 | + return yield* runner.run({ code }).pipe(Effect.ensuring(setCurrentCode(turnId, null))); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + yield* runAgentLoopWithAnthropic({ | |
| 178 | + prompt, | |
| 179 | + now, | |
| 180 | + toolPromptGuidance: params.toolPromptGuidance, | |
| 181 | + toolTypeDeclarations: params.toolTypeDeclarations, | |
| 182 | + runCode, | |
| 224 | 183 | }).pipe( |
| 225 | − Effect.flatMap((generated) => emitEvent(turnId, toCompletedEvent(turnId, generated, params.verboseFooter))), | |
| 226 | − Effect.catchAll((error) => | |
| 227 | − emitEvent(turnId, { | |
| 228 | − status: "failed", | |
| 229 | − turnId, | |
| 230 | − error, | |
| 231 | − }), | |
| 232 | − ), | |
| 184 | + Effect.matchEffect({ | |
| 185 | + onSuccess: (generated) => emitEvent(turnId, toCompletedEvent(turnId, generated)), | |
| 186 | + onFailure: (error) => emitEvent(turnId, toFailedEvent(turnId, describeUnknown(error))), | |
| 187 | + }), | |
| 233 | 188 | ); |
| 234 | 189 | }); |
| 235 | 190 | |
| @@ -268,31 +223,34 @@ function makeTurnManager(params: { · 32 unmodified lines | ||
| 268 | 223 | const waitForNext = Effect.fn("TurnManager.waitForNext")(function* (turnId: string) { |
| 269 | 224 | const deferred = yield* Deferred.make<TurnEvent>(); |
| 270 | 225 | |
| 271 | − const state = yield* Ref.modify(sessionsRef, (sessions): readonly [WaitForNextState, Map<string, TurnSession>] => { | |
| 272 | − const session = sessions.get(turnId); | |
| 273 | − if (!session) { | |
| 274 | − return [{ _tag: "missing" }, sessions]; | |
| 275 | − } | |
| 276 | − if (session.queue.length > 0) { | |
| 277 | − const event = session.queue.shift()!; | |
| 278 | − cleanupIfTerminal(sessions, session, event); | |
| 279 | − return [{ _tag: "event", event }, sessions]; | |
| 280 | − } | |
| 281 | − if (session.waitingEvent) { | |
| 282 | − return [{ _tag: "await", deferred: session.waitingEvent }, sessions]; | |
| 283 | − } | |
| 284 | − session.waitingEvent = deferred; | |
| 285 | − return [{ _tag: "await", deferred }, sessions]; | |
| 286 | − }); | |
| 226 | + const next = yield* Ref.modify( | |
| 227 | + sessionsRef, | |
| 228 | + (sessions): readonly [WaitForNextResult, Map<string, TurnSession>] => { | |
| 229 | + const session = sessions.get(turnId); | |
| 230 | + if (!session) { | |
| 231 | + return [{ kind: "missing" }, sessions]; | |
| 232 | + } | |
| 233 | + if (session.queue.length > 0) { | |
| 234 | + const event = session.queue.shift()!; | |
| 235 | + cleanupIfTerminal(sessions, session, event); | |
| 236 | + return [{ kind: "event", event }, sessions]; | |
| 237 | + } | |
| 238 | + if (session.waitingEvent) { | |
| 239 | + return [{ kind: "wait", deferred: session.waitingEvent }, sessions]; | |
| 240 | + } | |
| 241 | + session.waitingEvent = deferred; | |
| 242 | + return [{ kind: "wait", deferred }, sessions]; | |
| 243 | + }, | |
| 244 | + ); | |
| 287 | 245 | |
| 288 | − if (state._tag === "missing") { | |
| 246 | + if (next.kind === "missing") { | |
| 289 | 247 | return null; |
| 290 | 248 | } |
| 291 | − if (state._tag === "event") { | |
| 292 | − return state.event; | |
| 249 | + if (next.kind === "event") { | |
| 250 | + return next.event; | |
| 293 | 251 | } |
| 294 | 252 | |
| 295 | − const event = yield* Deferred.await(state.deferred); | |
| 253 | + const event = yield* Deferred.await(next.deferred); | |
| 296 | 254 | yield* Ref.update(sessionsRef, (sessions) => { |
| 297 | 255 | const session = sessions.get(turnId); |
| 298 | 256 | if (session) { |
| @@ -312,7 +270,7 @@ function makeTurnManager(params: { · 13 unmodified lines | ||
| 312 | 270 | }) { |
| 313 | 271 | const result = yield* Ref.modify( |
| 314 | 272 | sessionsRef, |
| 315 | − (sessions): readonly [ResolveApprovalState, Map<string, TurnSession>] => { | |
| 273 | + (sessions): readonly [ResolveApprovalResult, Map<string, TurnSession>] => { | |
| 316 | 274 | const session = sessions.get(input.turnId); |
| 317 | 275 | if (!session) { |
| 318 | 276 | return [{ status: "not_found" }, sessions]; |
| @@ -343,30 +301,56 @@ function makeTurnManager(params: { · 24 unmodified lines | ||
| 343 | 301 | return "resolved" as const; |
| 344 | 302 | }); |
| 345 | 303 | |
| 346 | − return TurnManager.of({ | |
| 304 | + return { | |
| 347 | 305 | start, |
| 348 | 306 | waitForNext, |
| 349 | 307 | resolveApproval, |
| 350 | − }); | |
| 308 | + }; | |
| 351 | 309 | }); |
| 352 | 310 | } |
| 353 | 311 | |
| 354 | −function toCompletedEvent(turnId: string, generated: AgentLoopResult, includeFooter: boolean): TurnResult { | |
| 312 | +function toCompletedEvent(turnId: string, generated: AgentLoopResult): TurnResult { | |
| 355 | 313 | return { |
| 356 | 314 | status: "completed", |
| 357 | 315 | turnId, |
| 358 | 316 | message: generated.text, |
| 359 | 317 | planner: generated.planner, |
| 360 | 318 | codeRuns: generated.runs.length, |
| 361 | − ...(includeFooter ? { footer: generated.planner } : {}), | |
| 319 | + footer: generated.planner, | |
| 320 | + }; | |
| 321 | +} | |
| 322 | + | |
| 323 | +function toApprovalEvent( | |
| 324 | + turnId: string, | |
| 325 | + request: ApprovalRequest, | |
| 326 | + formatted: ApprovalPresentation, | |
| 327 | + currentCode: string | null, | |
| 328 | +): TurnResult { | |
| 329 | + return { | |
| 330 | + status: "awaiting_approval", | |
| 331 | + turnId, | |
| 332 | + approval: { | |
| 333 | + callId: request.callId, | |
| 334 | + toolPath: request.toolPath, | |
| 335 | + title: formatted.title, | |
| 336 | + details: formatted.details, | |
| 337 | + link: formatted.link, | |
| 338 | + inputPreview: formatted.inputPreview, | |
| 339 | + codeSnippet: currentCode ? truncateCode(currentCode) : undefined, | |
| 340 | + }, | |
| 341 | + }; | |
| 342 | +} | |
| 343 | + | |
| 344 | +function toFailedEvent(turnId: string, error: string): TurnResult { | |
| 345 | + return { | |
| 346 | + status: "failed", | |
| 347 | + turnId, | |
| 348 | + error, | |
| 362 | 349 | }; |
| 363 | 350 | } |
| 364 | 351 | |
| 365 | 352 | function newTurnId(): string { |
| 366 | − if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { | |
| 367 | − return crypto.randomUUID(); | |
| 368 | − } | |
| 369 | − return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; | |
| 353 | + return crypto.randomUUID(); | |
| 370 | 354 | } |
| 371 | 355 | |
| 372 | 356 | function describeUnknown(error: unknown): string { |