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 @@
11 import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
2+import { Effect } from "effect";
23 import { describe, expect, it } from "vitest";
34 import { runAgentLoop } from "./agent-loop.js";
45
@@ -6,27 +7,35 @@ describe("runAgentLoop", () => { · 1 unmodified line
67 it("tracks three code runs when claude tool loop executes run_code three times", async () => {
78 let runCount = 0;
89
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));
2517 },
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+ }),
2737 );
2838
29− expect(result.provider).toBe("claude");
3039 expect(result.text).toContain("3 calendar events");
3140 expect(result.runs).toHaveLength(3);
3241 expect(runCount).toBe(3);
@@ -37,27 +46,30 @@ describe("runAgentLoop", () => { · 4 unmodified lines
3746 it("rejects invalid generated tool code via typecheck before execution", async () => {
3847 let runCount = 0;
3948
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));
5956 },
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+ }),
6173 );
6274
6375 expect(runCount).toBe(1);
@@ -73,45 +85,44 @@ describe("runAgentLoop", () => { · 9 unmodified lines
7385 it("returns a failure response when claude generation fails", async () => {
7486 const codeRuns: string[] = [];
7587
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");
88101
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);
93102 expect(codeRuns).toHaveLength(0);
94103 });
95104
96105 it("allows a zero-tool response when claude does not call run_code", async () => {
97106 const codeRuns: string[] = [];
98107
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+ }),
112124 );
113125
114− expect(result.provider).toBe("claude");
115126 expect(result.runs).toHaveLength(0);
116127 expect(codeRuns).toHaveLength(0);
117128 expect(result.planner).toContain("0 code runs");
apps/discord-bot/src/agent-loop.ts+106−126
@@ -1,73 +1,68 @@
11 import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
22 import { generateText, stepCountIs, tool } from "ai";
3+import { Effect } from "effect";
34 import { getAnthropicModel } from "./anthropic-provider.js";
45 import { typecheckCodeSnippet } from "./code-typecheck.js";
56 import { z } from "zod";
67
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 = {
820 code: string;
921 result: CodeModeRunResult;
10− reason?: string | undefined;
11−}
22+};
1223
13−export interface AgentLoopResult {
14− provider: "claude";
24+export type AgentLoopResult = {
1525 planner: string;
1626 text: string;
1727 runs: AgentCodeRun[];
18−}
28+};
1929
20−interface RunCodeToolInput {
21− code: string;
22− reason?: string | undefined;
23−}
30+type RunCodeToolInput = z.infer<typeof RUN_CODE_TOOL_SCHEMA>;
2431
25−interface RunCodeToolOutput {
32+type RunCodeToolOutput = {
2633 ok: boolean;
2734 receipts: ToolCallReceipt[];
2835 value?: unknown;
2936 error?: string;
30−}
37+};
3138
32−interface RunWithClaudeInput {
39+type PlannerInput = {
3340 prompt: string;
3441 now: Date;
35− executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>;
36−}
42+ executeCode: (input: RunCodeToolInput) => Effect.Effect<RunCodeToolOutput>;
43+};
3744
38−interface RunWithClaudeOutput {
45+type PlannerOutput = {
46+ plannerName: string;
3947 text: string;
4048 modelID: string;
4149 authSource: string;
42−}
43−
44−interface AgentLoopOptions {
45− now?: Date;
46− runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>;
47−}
50+};
4851
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>;
5553
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+};
6060
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) {
6762 const runs: AgentCodeRun[] = [];
6863
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);
7166 if (!typecheck.ok) {
7267 const failed: CodeModeRunResult = {
7368 ok: false,
@@ -75,100 +70,81 @@ export async function runAgentLoop( · 1 unmodified line
7570 receipts: [],
7671 };
7772 runs.push({
78− code: input.code,
73+ code: toolInput.code,
7974 result: failed,
80− ...(input.reason ? { reason: input.reason } : {}),
8175 });
82− return {
83− ok: false,
84− error: failed.error,
85− receipts: [],
86− };
76+ return toRunCodeToolOutput(failed);
8777 }
8878
89− const result = await runCode(input.code);
79+ const result = yield* input.runCode(toolInput.code);
9080 runs.push({
91− code: input.code,
81+ code: toolInput.code,
9282 result,
93− ...(input.reason ? { reason: input.reason } : {}),
9483 });
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,
10996 };
97+});
11098
111− try {
112− const generated = await (options.runWithClaude ?? runWithClaude)({
113− prompt,
114− now,
115− executeCode,
116− });
99+export type RunAgentLoopWithAnthropicInput = Omit<RunAgentLoopInput, "runPlanner">;
117100
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+});
133109
134−async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> {
110+const runAnthropicPlanner = Effect.fn("AgentLoop.runAnthropicPlanner")(function* (input: PlannerInput) {
135111 const abortController = new AbortController();
136112 const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS);
137113
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+ });
166133
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+});
171146
147+function buildSystemPrompt(): string {
172148 return [
173149 "You are OpenAssistant. Execute work via the run_code tool and then report what happened.",
174150 "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
188164 ].join("\n");
189165 }
190166
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+ };
194174 }
195− return String(error);
196−}
197175
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+ };
201181 }
apps/gateway/src/agent-loop.ts+113−136
@@ -1,77 +1,71 @@
11 import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
22 import { generateText, stepCountIs, tool } from "ai";
3+import { Effect } from "effect";
34 import { getAnthropicModel } from "./anthropic-provider.js";
45 import { typecheckCodeSnippet } from "./code-typecheck.js";
56 import { z } from "zod";
67
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 = {
820 code: string;
921 result: CodeModeRunResult;
10− reason?: string | undefined;
11−}
22+};
1223
13−export interface AgentLoopResult {
14− provider: "claude";
24+export type AgentLoopResult = {
1525 planner: string;
1626 text: string;
1727 runs: AgentCodeRun[];
18−}
28+};
1929
20−interface RunCodeToolInput {
21− code: string;
22− reason?: string | undefined;
23−}
30+type RunCodeToolInput = z.infer<typeof RUN_CODE_TOOL_SCHEMA>;
2431
25−interface RunCodeToolOutput {
32+type RunCodeToolOutput = {
2633 ok: boolean;
2734 receipts: ToolCallReceipt[];
2835 value?: unknown;
2936 error?: string;
30−}
37+};
3138
32−interface RunWithClaudeInput {
39+type PlannerInput = {
3340 prompt: string;
3441 now: Date;
35− toolPromptGuidance?: string;
36− executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>;
37−}
42+ toolPromptGuidance: string;
43+ executeCode: (input: RunCodeToolInput) => Effect.Effect<RunCodeToolOutput>;
44+};
3845
39−interface RunWithClaudeOutput {
46+type PlannerOutput = {
47+ plannerName: string;
4048 text: string;
4149 modelID: string;
4250 authSource: string;
43−}
51+};
4452
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>;
5154
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+};
6363
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) {
7065 const runs: AgentCodeRun[] = [];
7166
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);
7569 if (!typecheck.ok) {
7670 const failed: CodeModeRunResult = {
7771 ok: false,
@@ -79,108 +73,89 @@ export async function runAgentLoop( · 1 unmodified line
7973 receipts: [],
8074 };
8175 runs.push({
82− code: input.code,
76+ code: toolInput.code,
8377 result: failed,
84− ...(input.reason ? { reason: input.reason } : {}),
8578 });
86− return {
87− ok: false,
88− error: failed.error,
89− receipts: [],
90− };
79+ return toRunCodeToolOutput(failed);
9180 }
9281
93− const result = await runCode(input.code);
82+ const result = yield* input.runCode(toolInput.code);
9483 runs.push({
95− code: input.code,
84+ code: toolInput.code,
9685 result,
97− ...(input.reason ? { reason: input.reason } : {}),
9886 });
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,
113100 };
101+});
114102
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">;
122104
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+});
138113
139−async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> {
114+const runAnthropicPlanner = Effect.fn("AgentLoop.runAnthropicPlanner")(function* (input: PlannerInput) {
140115 const abortController = new AbortController();
141116 const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS);
142117
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+ });
171137
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+});
176150
151+function buildSystemPrompt(): string {
177152 return [
178153 "You are OpenAssistant. Execute work via the run_code tool and then report what happened.",
179154 "In your final response, summarize relevant tool activity and mention failures/denials clearly.",
180155 ].join("\n");
181156 }
182157
183−function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance?: string): string {
158+function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance: string): string {
184159 return [
185160 "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).",
186161 "Inside code, call available tools directly using dot notation.",
@@ -189,22 +164,24 @@ function buildAgentPrompt(userPrompt: string, now: Date, toolPromptGuidance?: st · 2 unmodified lines
189164 "Only issue another run_code call when the prior run failed or was denied and you are retrying with a fix.",
190165 "For bulk changes, use read operations first, then write operations one-by-one.",
191166 "Never claim an action succeeded unless run_code returned ok=true.",
192− toolPromptGuidance ? `Available tools:\n${toolPromptGuidance}` : null,
167+ `Available tools:\n${toolPromptGuidance}`,
193168 `Current timestamp: ${now.toISOString()}`,
194169 `User request: ${userPrompt}`,
195− ]
196− .filter((line): line is string => line !== null)
197− .join("\n");
170+ ].join("\n");
198171 }
199172
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+ };
203180 }
204− return String(error);
205−}
206181
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+ };
210187 }
apps/gateway/src/index.ts+15−32
@@ -7,13 +7,12 @@ import { AgentRpcs, ResolveApprovalOutput, type TurnResult } from "./rpc.js"; · 6 unmodified lines
77 import { createToolingBundle } from "./tools.js";
88 import { TurnManager } from "./turn-manager.js";
99
10−const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787");
10+const port = Number(Bun.env.OPENASSISTANT_GATEWAY_PORT ?? "8787");
1111
1212 const calendarStore = new InMemoryCalendarStore();
1313 const tooling = createToolingBundle(calendarStore);
1414 const TurnManagerLayer = TurnManager.layer({
1515 tools: tooling.tools,
16− verboseFooter: isVerboseMode(),
1716 toolPromptGuidance: tooling.promptGuidance,
1817 toolTypeDeclarations: tooling.toolTypeDeclarations,
1918 formatApproval: tooling.formatApproval,
@@ -22,6 +21,18 @@ const TurnManagerLayer = TurnManager.layer({ · 2 unmodified lines
2221 const AgentHandlersLive = AgentRpcs.toLayer(
2322 Effect.gen(function* () {
2423 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+
2536 return {
2637 RunTurn: (input: { prompt: string; requesterId: string; channelId: string; nowIso: string }) =>
2738 Effect.gen(function* () {
@@ -31,28 +42,10 @@ const AgentHandlersLive = AgentRpcs.toLayer( · 3 unmodified lines
3142 channelId: input.channelId,
3243 now: new Date(input.nowIso),
3344 });
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);
4346 }),
4447 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),
5649 ResolveApproval: (input: { turnId: string; callId: string; actorId: string; decision: "approved" | "denied" }) =>
5750 Effect.gen(function* () {
5851 const status = yield* turnManager.resolveApproval({
@@ -81,13 +74,3 @@ const MainLayer = HttpRouter.Default.serve().pipe( · 22 unmodified lines
8174
8275 console.log(`[gateway] listening on http://localhost:${port}/rpc`);
8376 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−}
apps/gateway/src/turn-manager.ts+102−118
@@ -5,7 +5,7 @@ import { · 4 unmodified lines
55 type ToolTree,
66 } from "@openassistant/core";
77 import type { AgentLoopResult } from "./agent-loop.js";
8−import { runAgentLoop } from "./agent-loop.js";
8+import { runAgentLoopWithAnthropic } from "./agent-loop.js";
99 import type { ApprovalPresentation } from "./plugins/plugin-system.js";
1010 import { type TurnResult } from "./rpc.js";
1111 import { Context, Deferred, Effect, Layer, Ref } from "effect";
@@ -31,14 +31,21 @@ type TurnSession = { · 19 unmodified lines
3131 completed: boolean;
3232 };
3333
34+type TurnManagerParams = {
35+ tools: ToolTree;
36+ toolPromptGuidance: string;
37+ toolTypeDeclarations: string;
38+ formatApproval: (request: ApprovalRequest) => ApprovalPresentation;
39+};
40+
3441 export type ResolveApprovalStatus = "resolved" | "not_found" | "unauthorized";
3542
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> };
4047
41−type ResolveApprovalState =
48+type ResolveApprovalResult =
4249 | { status: "not_found" }
4350 | { status: "unauthorized" }
4451 | {
@@ -47,42 +54,18 @@ type ResolveApprovalState = · 2 unmodified lines
4754 nextWaiter: Deferred.Deferred<void> | null;
4855 };
4956
57+type TurnManagerService = Effect.Effect.Success<ReturnType<typeof makeTurnManager>>;
58+
5059 export class TurnManager extends Context.Tag("@openassistant/gateway/TurnManager")<
5160 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
6762 >() {
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> {
7564 return Layer.effect(TurnManager, makeTurnManager(params));
7665 }
7766 }
7867
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) {
8669 return Effect.gen(function* () {
8770 const sessionsRef = yield* Ref.make(new Map<string, TurnSession>());
8871
@@ -165,71 +148,43 @@ function makeTurnManager(params: { · 76 unmodified lines
165148
166149 const formatted = params.formatApproval(request);
167150
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));
181152
182153 return yield* Deferred.await(decision);
183154 });
184155
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+
185166 const runSession = Effect.fn("TurnManager.runSession")(function* (turnId: string, prompt: string, now: Date) {
186167 const runner = createCodeModeRunner({
187168 tools: params.tools,
188169 requestApproval: (request) => requestApproval(turnId, request),
189170 });
190171
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,
224183 }).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+ }),
233188 );
234189 });
235190
@@ -268,31 +223,34 @@ function makeTurnManager(params: { · 32 unmodified lines
268223 const waitForNext = Effect.fn("TurnManager.waitForNext")(function* (turnId: string) {
269224 const deferred = yield* Deferred.make<TurnEvent>();
270225
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+ );
287245
288− if (state._tag === "missing") {
246+ if (next.kind === "missing") {
289247 return null;
290248 }
291− if (state._tag === "event") {
292− return state.event;
249+ if (next.kind === "event") {
250+ return next.event;
293251 }
294252
295− const event = yield* Deferred.await(state.deferred);
253+ const event = yield* Deferred.await(next.deferred);
296254 yield* Ref.update(sessionsRef, (sessions) => {
297255 const session = sessions.get(turnId);
298256 if (session) {
@@ -312,7 +270,7 @@ function makeTurnManager(params: { · 13 unmodified lines
312270 }) {
313271 const result = yield* Ref.modify(
314272 sessionsRef,
315− (sessions): readonly [ResolveApprovalState, Map<string, TurnSession>] => {
273+ (sessions): readonly [ResolveApprovalResult, Map<string, TurnSession>] => {
316274 const session = sessions.get(input.turnId);
317275 if (!session) {
318276 return [{ status: "not_found" }, sessions];
@@ -343,30 +301,56 @@ function makeTurnManager(params: { · 24 unmodified lines
343301 return "resolved" as const;
344302 });
345303
346− return TurnManager.of({
304+ return {
347305 start,
348306 waitForNext,
349307 resolveApproval,
350− });
308+ };
351309 });
352310 }
353311
354−function toCompletedEvent(turnId: string, generated: AgentLoopResult, includeFooter: boolean): TurnResult {
312+function toCompletedEvent(turnId: string, generated: AgentLoopResult): TurnResult {
355313 return {
356314 status: "completed",
357315 turnId,
358316 message: generated.text,
359317 planner: generated.planner,
360318 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,
362349 };
363350 }
364351
365352 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();
370354 }
371355
372356 function describeUnknown(error: unknown): string {