Skip to content

Commit b057bfb

Browse files
authored
feat(desktop): Implement loops ➿ (#3411)
1 parent 7265430 commit b057bfb

72 files changed

Lines changed: 6909 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { PostHogAPIConfig } from "../../types";
4+
5+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
6+
query: vi.fn(),
7+
}));
8+
9+
const mocks = vi.hoisted(() => ({
10+
updateTaskRun: vi.fn(),
11+
constructedConfigs: [] as unknown[],
12+
}));
13+
14+
vi.mock("../../posthog-api", async (importOriginal) => {
15+
const actual = await importOriginal<typeof import("../../posthog-api")>();
16+
class FakePostHogAPIClient {
17+
updateTaskRun = mocks.updateTaskRun;
18+
constructor(config: unknown) {
19+
mocks.constructedConfigs.push(config);
20+
}
21+
}
22+
return { ...actual, PostHogAPIClient: FakePostHogAPIClient };
23+
});
24+
25+
const { ClaudeAcpAgent } = await import("./claude-agent");
26+
27+
const API_CONFIG: PostHogAPIConfig = {
28+
apiUrl: "https://us.posthog.com",
29+
getApiKey: () => "key",
30+
projectId: 1,
31+
};
32+
33+
type RequestFinish = (
34+
status: "completed" | "failed",
35+
message?: string,
36+
) => Promise<void>;
37+
38+
function buildRequestFinish(
39+
posthogApiConfig: PostHogAPIConfig | undefined,
40+
taskId: string | undefined,
41+
taskRunId: string | undefined,
42+
): RequestFinish | undefined {
43+
const client = {
44+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
45+
} as unknown as AgentSideConnection;
46+
const agent = new ClaudeAcpAgent(client, { posthogApiConfig });
47+
return (
48+
agent as unknown as {
49+
buildRequestFinish(
50+
taskId: string | undefined,
51+
taskRunId: string | undefined,
52+
): RequestFinish | undefined;
53+
}
54+
).buildRequestFinish(taskId, taskRunId);
55+
}
56+
57+
describe("ClaudeAcpAgent.buildRequestFinish", () => {
58+
beforeEach(() => {
59+
mocks.updateTaskRun.mockReset().mockResolvedValue({});
60+
mocks.constructedConfigs.length = 0;
61+
});
62+
63+
it.each([
64+
{
65+
name: "no posthogApiConfig",
66+
config: undefined,
67+
taskId: "task-1",
68+
taskRunId: "run-1",
69+
},
70+
{
71+
name: "no taskId",
72+
config: API_CONFIG,
73+
taskId: undefined,
74+
taskRunId: "run-1",
75+
},
76+
{
77+
name: "no taskRunId",
78+
config: API_CONFIG,
79+
taskId: "task-1",
80+
taskRunId: undefined,
81+
},
82+
])("is unavailable with $name", ({ config, taskId, taskRunId }) => {
83+
expect(buildRequestFinish(config, taskId, taskRunId)).toBeUndefined();
84+
});
85+
86+
it("marks the run completed without an error_message", async () => {
87+
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
88+
await requestFinish?.("completed");
89+
90+
expect(mocks.constructedConfigs).toEqual([API_CONFIG]);
91+
expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
92+
status: "completed",
93+
});
94+
});
95+
96+
it("marks the run failed with the message as error_message", async () => {
97+
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
98+
await requestFinish?.("failed", "blocked on missing credentials");
99+
100+
expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
101+
status: "failed",
102+
error_message: "blocked on missing credentials",
103+
});
104+
});
105+
106+
it("omits error_message on a failed finish without a message", async () => {
107+
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
108+
await requestFinish?.("failed");
109+
110+
expect(mocks.updateTaskRun).toHaveBeenCalledWith("task-1", "run-1", {
111+
status: "failed",
112+
});
113+
});
114+
115+
it("rethrows when the API update fails", async () => {
116+
mocks.updateTaskRun.mockRejectedValue(new Error("api down"));
117+
const requestFinish = buildRequestFinish(API_CONFIG, "task-1", "run-1");
118+
119+
await expect(requestFinish?.("completed")).rejects.toThrow("api down");
120+
});
121+
});

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
type Enrichment,
5858
type FileEnrichmentDeps,
5959
} from "../../enrichment/file-enricher";
60+
import { PostHogAPIClient } from "../../posthog-api";
6061
import { resolvePostHogExecPermissionRegex } from "../../posthog-exec-permission";
6162
import {
6263
classifyPostHogExecCall,
@@ -75,7 +76,7 @@ import { resolveGithubToken } from "../../utils/github-token";
7576
import { Logger } from "../../utils/logger";
7677
import { Pushable } from "../../utils/streams";
7778
import { BaseAcpAgent } from "../base-acp-agent";
78-
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
79+
import { LOCAL_TOOLS_MCP_NAME, type LocalToolCtx } from "../local-tools";
7980
import { resolveSpokenNarration, resolveTaskId } from "../session-meta";
8081
import {
8182
buildBreakdown,
@@ -1881,6 +1882,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
18811882
}
18821883
}
18831884

1885+
// Backs the `finish` local tool: marks the task run terminal so the Temporal
1886+
// workflow tears the sandbox down. Only wired when we have both the run
1887+
// identifiers and a PostHog API config, i.e. a real cloud run.
1888+
private buildRequestFinish(
1889+
taskId: string | undefined,
1890+
taskRunId: string | undefined,
1891+
): LocalToolCtx["requestFinish"] {
1892+
const config = this.options?.posthogApiConfig;
1893+
if (!config || !taskId || !taskRunId) {
1894+
return undefined;
1895+
}
1896+
return async (status, message) => {
1897+
try {
1898+
await new PostHogAPIClient(config).updateTaskRun(taskId, taskRunId, {
1899+
status,
1900+
...(status === "failed" && message ? { error_message: message } : {}),
1901+
});
1902+
} catch (error) {
1903+
this.logger.error("finish tool failed to mark run terminal", error);
1904+
throw error;
1905+
}
1906+
};
1907+
}
1908+
18841909
private async createSession(
18851910
params: {
18861911
cwd: string;
@@ -1939,6 +1964,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19391964
const baseBranch = meta?.baseBranch;
19401965
const environment = meta?.environment;
19411966
const spokenNarration = resolveSpokenNarration(meta);
1967+
const requestFinish = this.buildRequestFinish(taskId, meta?.taskRunId);
19421968
const buildInProcessMcpServers = (): Record<
19431969
string,
19441970
McpSdkServerConfigWithInstance
@@ -1950,8 +1976,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19501976
taskId,
19511977
taskRunId: meta?.taskRunId,
19521978
baseBranch,
1979+
requestFinish,
1980+
},
1981+
{
1982+
environment,
1983+
spokenNarration,
1984+
background: meta?.mode === "background",
19531985
},
1954-
{ environment, spokenNarration },
19551986
);
19561987
return server ? { [LOCAL_TOOLS_MCP_NAME]: server } : {};
19571988
};

packages/agent/src/adapters/claude/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
SDKUserMessage,
1212
} from "@anthropic-ai/claude-agent-sdk";
1313
import type { PostHogProductId } from "../../posthog-products";
14+
import type { AgentMode } from "../../types";
1415
import type { Pushable } from "../../utils/streams";
1516
import type { BaseSession } from "../base-acp-agent";
1617
import type { ContextBreakdownBaseline } from "./context-breakdown";
@@ -181,6 +182,12 @@ export type NewSessionMeta = {
181182
taskRunId?: string;
182183
taskId?: string;
183184
environment?: "local" | "cloud";
185+
/**
186+
* Run mode. "background" means unattended (loops, durable ingest) — no human
187+
* drives the turns, so the agent may end its own run via the `finish` tool.
188+
* "interactive" runs are driven turn-by-turn and are ended by the human.
189+
*/
190+
mode?: AgentMode;
184191
disableBuiltInTools?: boolean;
185192
systemPrompt?: unknown;
186193
sessionId?: string;

packages/agent/src/adapters/local-tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
22
import { cloneRepoTool } from "./tools/clone-repo";
3+
import { finishTool } from "./tools/finish";
34
import { listReposTool } from "./tools/list-repos";
45
import { signedCommitTool } from "./tools/signed-commit";
56
import { signedMergeTool } from "./tools/signed-merge";
@@ -23,6 +24,7 @@ export const LOCAL_TOOLS: LocalTool[] = [
2324
listReposTool,
2425
cloneRepoTool,
2526
speakTool,
27+
finishTool,
2628
];
2729

2830
/** Tools whose gate passes for the given context — the set to actually expose. */

packages/agent/src/adapters/local-tools/registry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export interface LocalToolCtx {
2121
* back to origin/HEAD detection when unset.
2222
*/
2323
baseBranch?: string;
24+
/**
25+
* Marks this run terminal and lets the workflow tear the sandbox down. Set by
26+
* the adapter for cloud runs that own a sandbox; absent for local sessions.
27+
* Powers the `finish` tool so an unattended run ends the moment the agent
28+
* decides it's done, instead of idling until a timeout.
29+
*/
30+
requestFinish?: (
31+
status: "completed" | "failed",
32+
message?: string,
33+
) => Promise<void>;
2434
}
2535

2636
/** Minimal session-meta shape needed to gate tools (e.g. cloud-only). */
@@ -30,6 +40,12 @@ export interface LocalToolGateMeta {
3040
channelMode?: boolean;
3141
/** Spoken narration is on for this session: enables the speak tool. */
3242
spokenNarration?: boolean;
43+
/**
44+
* Unattended run (no human driving turns): enables the `finish` tool so the
45+
* agent can end its own run. False/undefined for interactive runs, which a
46+
* human ends.
47+
*/
48+
background?: boolean;
3349
}
3450

3551
/**
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { enabledLocalTools } from "../index";
3+
import type { LocalToolCtx, LocalToolGateMeta } from "../registry";
4+
import { FINISH_TOOL_NAME, finishSchema, finishTool } from "./finish";
5+
6+
describe("finish tool", () => {
7+
const requestFinish = async (): Promise<void> => {};
8+
9+
it.each([
10+
{
11+
name: "background cloud run with requestFinish",
12+
ctx: { cwd: "/repo", requestFinish },
13+
meta: { environment: "cloud", background: true },
14+
expected: true,
15+
},
16+
{
17+
name: "background cloud run without requestFinish",
18+
ctx: { cwd: "/repo" },
19+
meta: { environment: "cloud", background: true },
20+
expected: false,
21+
},
22+
{
23+
name: "interactive cloud run (background unset)",
24+
ctx: { cwd: "/repo", requestFinish },
25+
meta: { environment: "cloud" },
26+
expected: false,
27+
},
28+
{
29+
name: "interactive cloud run (background false)",
30+
ctx: { cwd: "/repo", requestFinish },
31+
meta: { environment: "cloud", background: false },
32+
expected: false,
33+
},
34+
{
35+
name: "background local run",
36+
ctx: { cwd: "/repo", requestFinish },
37+
meta: { environment: "local", background: true },
38+
expected: false,
39+
},
40+
{
41+
name: "no gate meta",
42+
ctx: { cwd: "/repo", requestFinish },
43+
meta: undefined,
44+
expected: false,
45+
},
46+
] as {
47+
name: string;
48+
ctx: LocalToolCtx;
49+
meta: LocalToolGateMeta | undefined;
50+
expected: boolean;
51+
}[])("is exposed only for $name → $expected", ({ ctx, meta, expected }) => {
52+
const tools = enabledLocalTools(ctx, meta);
53+
expect(tools.some((t) => t.name === FINISH_TOOL_NAME)).toBe(expected);
54+
});
55+
56+
it("stays visible without ToolSearch (alwaysLoad)", () => {
57+
expect(finishTool.alwaysLoad).toBe(true);
58+
});
59+
60+
it("defaults status to completed", () => {
61+
expect(finishSchema.status.parse(undefined)).toBe("completed");
62+
});
63+
64+
it("rejects an unknown status", () => {
65+
expect(finishSchema.status.safeParse("aborted").success).toBe(false);
66+
});
67+
68+
it("marks the run terminal via requestFinish", async () => {
69+
const spy = vi.fn(async () => {});
70+
const result = await finishTool.handler(
71+
{ cwd: "/repo", requestFinish: spy },
72+
{ status: "failed", reason: "ran out of quota" },
73+
);
74+
expect(spy).toHaveBeenCalledWith("failed", "ran out of quota");
75+
expect(result.isError).toBeUndefined();
76+
});
77+
78+
it("errors when requestFinish is unavailable", async () => {
79+
const result = await finishTool.handler(
80+
{ cwd: "/repo" },
81+
{ status: "completed" },
82+
);
83+
expect(result.isError).toBe(true);
84+
});
85+
});

0 commit comments

Comments
 (0)