History

simplify

Rhys Sullivan committed 9 months ago

Commit b3ffbbe1 parent eba79b85 files · +415 −480Browse files

Unified · Split

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