@agentstep/agent-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome grader — evaluates agent output against a rubric.
|
|
3
|
+
*
|
|
4
|
+
* Runs as a direct Anthropic API call from the Node.js process (NOT
|
|
5
|
+
* `claude -p` on the container) to avoid corrupting Claude session state.
|
|
6
|
+
* Uses tool_use with a structured result schema for reliable parsing.
|
|
7
|
+
*/
|
|
8
|
+
import { getConfig } from "../config";
|
|
9
|
+
|
|
10
|
+
export type GraderResult = "satisfied" | "needs_revision" | "failed";
|
|
11
|
+
|
|
12
|
+
export interface GraderEvaluation {
|
|
13
|
+
result: GraderResult;
|
|
14
|
+
feedback: string;
|
|
15
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GRADER_SYSTEM = `You are an evaluation grader. You evaluate whether an agent's work output satisfies a rubric.
|
|
19
|
+
|
|
20
|
+
You MUST call the evaluate_outcome tool with your assessment. Do not respond with plain text.`;
|
|
21
|
+
|
|
22
|
+
const EVALUATE_TOOL = {
|
|
23
|
+
name: "evaluate_outcome",
|
|
24
|
+
description: "Submit your evaluation of the agent's output against the rubric.",
|
|
25
|
+
input_schema: {
|
|
26
|
+
type: "object" as const,
|
|
27
|
+
properties: {
|
|
28
|
+
result: {
|
|
29
|
+
type: "string" as const,
|
|
30
|
+
enum: ["satisfied", "needs_revision", "failed"],
|
|
31
|
+
description: "satisfied: output meets the rubric. needs_revision: output is close but needs specific changes. failed: output fundamentally fails to meet the rubric.",
|
|
32
|
+
},
|
|
33
|
+
feedback: {
|
|
34
|
+
type: "string" as const,
|
|
35
|
+
description: "Brief explanation of the evaluation. If needs_revision, include specific actionable feedback for the agent.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["result", "feedback"],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Call the Anthropic API directly to grade agent output against a rubric.
|
|
44
|
+
* Falls back to a simple "satisfied" if the API key is not configured.
|
|
45
|
+
*/
|
|
46
|
+
export async function runGraderEvaluation(
|
|
47
|
+
rubric: string,
|
|
48
|
+
agentOutput: string,
|
|
49
|
+
model: string,
|
|
50
|
+
): Promise<GraderEvaluation> {
|
|
51
|
+
const cfg = getConfig();
|
|
52
|
+
const apiKey = cfg.anthropicApiKey;
|
|
53
|
+
|
|
54
|
+
if (!apiKey) {
|
|
55
|
+
// No API key — can't grade, default to satisfied
|
|
56
|
+
return {
|
|
57
|
+
result: "satisfied",
|
|
58
|
+
feedback: "Grader skipped: no ANTHROPIC_API_KEY configured for direct API evaluation.",
|
|
59
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const userMessage = `## Rubric\n${rubric}\n\n## Agent Output\n${agentOutput}`;
|
|
64
|
+
|
|
65
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
"x-api-key": apiKey,
|
|
70
|
+
"anthropic-version": "2023-06-01",
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model: model || "claude-sonnet-4-20250514",
|
|
74
|
+
max_tokens: 1024,
|
|
75
|
+
system: GRADER_SYSTEM,
|
|
76
|
+
tools: [EVALUATE_TOOL],
|
|
77
|
+
tool_choice: { type: "tool", name: "evaluate_outcome" },
|
|
78
|
+
messages: [{ role: "user", content: userMessage }],
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const errText = await response.text().catch(() => "");
|
|
84
|
+
console.warn(`[grader] API call failed (${response.status}): ${errText}`);
|
|
85
|
+
return {
|
|
86
|
+
result: "satisfied",
|
|
87
|
+
feedback: `Grader API error (${response.status}), defaulting to satisfied.`,
|
|
88
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json() as {
|
|
93
|
+
content: Array<{ type: string; name?: string; input?: { result?: string; feedback?: string } }>;
|
|
94
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Extract the tool_use result
|
|
98
|
+
const toolUse = data.content.find(
|
|
99
|
+
(b) => b.type === "tool_use" && b.name === "evaluate_outcome",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!toolUse?.input?.result) {
|
|
103
|
+
return {
|
|
104
|
+
result: "satisfied",
|
|
105
|
+
feedback: "Grader returned no structured result, defaulting to satisfied.",
|
|
106
|
+
usage: data.usage ?? { input_tokens: 0, output_tokens: 0 },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const validResults: GraderResult[] = ["satisfied", "needs_revision", "failed"];
|
|
111
|
+
const result = validResults.includes(toolUse.input.result as GraderResult)
|
|
112
|
+
? (toolUse.input.result as GraderResult)
|
|
113
|
+
: "satisfied";
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
result,
|
|
117
|
+
feedback: toolUse.input.feedback ?? "",
|
|
118
|
+
usage: data.usage ?? { input_tokens: 0, output_tokens: 0 },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interrupt handler.
|
|
3
|
+
*
|
|
4
|
+
* Must be called from inside the session's actor so the interrupt lands
|
|
5
|
+
* between two consecutive event appends, never in the middle of one.
|
|
6
|
+
*/
|
|
7
|
+
import { getRuntime } from "../state";
|
|
8
|
+
|
|
9
|
+
export function interruptSession(sessionId: string): boolean {
|
|
10
|
+
const run = getRuntime().inFlightRuns.get(sessionId);
|
|
11
|
+
if (!run) return false;
|
|
12
|
+
run.controller.abort(new DOMException("interrupted", "AbortError"));
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic sweeper: idle eviction + orphan reconcile.
|
|
3
|
+
*
|
|
4
|
+
* One unified sweeper with one `setInterval`, installed in `lib/init.ts`.
|
|
5
|
+
* One timer means one set of interleaving concerns.
|
|
6
|
+
*
|
|
7
|
+
* Park/restore as originally planned is infeasible (spike S2: sprites.dev
|
|
8
|
+
* checkpoints are per-sprite only; no stopSprite API). The correct M5
|
|
9
|
+
* model is: pin the sprite for the session's lifetime, and evict the
|
|
10
|
+
* session (release the sprite, terminate the row) after idle TTL expires.
|
|
11
|
+
*
|
|
12
|
+
* Re-entrancy: `sweeping` prevents overlapping ticks from stacking if a
|
|
13
|
+
* prior sweep runs longer than the interval. A stuck `releaseSession` can
|
|
14
|
+
* take up to `spriteTimeoutMs` (30s default) per candidate, so a bad run
|
|
15
|
+
* with 50 candidates could exceed the 60s interval.
|
|
16
|
+
*
|
|
17
|
+
* Shutdown cooperation: `stopping` is set by the shutdown handler so the
|
|
18
|
+
* sweep bails out cleanly between candidates if SIGTERM fires mid-run.
|
|
19
|
+
*/
|
|
20
|
+
import { getDb } from "../db/client";
|
|
21
|
+
import { getConfig } from "../config";
|
|
22
|
+
import { nowMs } from "../util/clock";
|
|
23
|
+
import { getRuntime } from "../state";
|
|
24
|
+
import { getActor, dropActor } from "./actor";
|
|
25
|
+
import { appendEvent, dropEmitter } from "./bus";
|
|
26
|
+
import {
|
|
27
|
+
archiveSession,
|
|
28
|
+
getSessionRow,
|
|
29
|
+
updateSessionStatus,
|
|
30
|
+
} from "../db/sessions";
|
|
31
|
+
import { releaseSession, reconcileOrphans, reconcileDockerOrphans } from "../sprite/lifecycle";
|
|
32
|
+
|
|
33
|
+
let sweeping = false;
|
|
34
|
+
let stopping = false;
|
|
35
|
+
|
|
36
|
+
export function markStopping(): void {
|
|
37
|
+
stopping = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Exposed for tests only. */
|
|
41
|
+
export function __resetSweeperState(): void {
|
|
42
|
+
sweeping = false;
|
|
43
|
+
stopping = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runSweep(): Promise<void> {
|
|
47
|
+
if (sweeping || stopping) return;
|
|
48
|
+
sweeping = true;
|
|
49
|
+
try {
|
|
50
|
+
try {
|
|
51
|
+
await evictIdleSessions();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.warn("[sweeper] evict failed:", e);
|
|
54
|
+
}
|
|
55
|
+
const cfg = getConfig();
|
|
56
|
+
if (cfg.spriteToken) {
|
|
57
|
+
try {
|
|
58
|
+
await reconcileOrphans();
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn("[sweeper] reconcile sprites failed:", e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await reconcileDockerOrphans();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Docker not available — skip silently
|
|
67
|
+
if (!(e instanceof Error) || !e.message.includes("ENOENT")) {
|
|
68
|
+
console.warn("[sweeper] reconcile docker failed:", e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
sweeping = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function evictIdleSessions(): Promise<void> {
|
|
77
|
+
if (stopping) return;
|
|
78
|
+
const cfg = getConfig();
|
|
79
|
+
const now = nowMs();
|
|
80
|
+
|
|
81
|
+
// COALESCE so sessions that never ran a turn (idle_since IS NULL) still
|
|
82
|
+
// age out from their created_at. LIMIT caps the worst case per sweep.
|
|
83
|
+
const rows = getDb()
|
|
84
|
+
.prepare(
|
|
85
|
+
`SELECT id FROM sessions
|
|
86
|
+
WHERE status = 'idle'
|
|
87
|
+
AND archived_at IS NULL
|
|
88
|
+
AND COALESCE(idle_since, created_at) + ? < ?
|
|
89
|
+
LIMIT ?`,
|
|
90
|
+
)
|
|
91
|
+
.all(cfg.sessionMaxAgeMs, now, 50) as { id: string }[];
|
|
92
|
+
|
|
93
|
+
if (rows.length === 0) return;
|
|
94
|
+
|
|
95
|
+
for (const { id: sessionId } of rows) {
|
|
96
|
+
if (stopping) return;
|
|
97
|
+
try {
|
|
98
|
+
await getActor(sessionId).enqueue(async () => {
|
|
99
|
+
// CRITICAL: runTurn executes OUTSIDE the actor lock (see
|
|
100
|
+
// app/v1/sessions/[id]/events/route.ts — enqueueTurn launches runTurn
|
|
101
|
+
// after releasing the actor). Checking session.status in the DB is
|
|
102
|
+
// not enough — it may still be "idle" for a few ms after a fresh
|
|
103
|
+
// POST /events fired off a new turn. The in-memory inFlightRuns map
|
|
104
|
+
// is the authoritative "turn in progress" signal.
|
|
105
|
+
const rt = getRuntime();
|
|
106
|
+
if (rt.inFlightRuns.has(sessionId)) return;
|
|
107
|
+
|
|
108
|
+
const row = getSessionRow(sessionId);
|
|
109
|
+
if (!row || row.status !== "idle" || row.archived_at != null) return;
|
|
110
|
+
|
|
111
|
+
// Re-check the TTL inside the lock — if another code path already
|
|
112
|
+
// bumped idle_since forward (turn completed), bail.
|
|
113
|
+
const base = row.idle_since ?? row.created_at;
|
|
114
|
+
if (base + cfg.sessionMaxAgeMs >= now) return;
|
|
115
|
+
|
|
116
|
+
await releaseSession(sessionId);
|
|
117
|
+
|
|
118
|
+
appendEvent(sessionId, {
|
|
119
|
+
type: "session.status_terminated",
|
|
120
|
+
payload: { reason: "idle_ttl" },
|
|
121
|
+
origin: "server",
|
|
122
|
+
processedAt: nowMs(),
|
|
123
|
+
});
|
|
124
|
+
updateSessionStatus(sessionId, "terminated", "idle_ttl");
|
|
125
|
+
archiveSession(sessionId);
|
|
126
|
+
});
|
|
127
|
+
dropActor(sessionId);
|
|
128
|
+
dropEmitter(sessionId);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Per-candidate isolation: one stuck session must not block the rest
|
|
131
|
+
// of the sweep. `releaseSession` is already best-effort internally,
|
|
132
|
+
// so this catch mainly protects against appendEvent/DB failures.
|
|
133
|
+
console.warn(`[sweeper] evict ${sessionId} failed:`, err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-agent thread orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* When a parent session's agent calls `spawn_agent`, the driver delegates
|
|
5
|
+
* to this module. It creates a child session, runs it to completion, and
|
|
6
|
+
* returns the child's final agent.message text as the tool result.
|
|
7
|
+
*
|
|
8
|
+
* Depth is capped at MAX_THREAD_DEPTH to prevent infinite recursion.
|
|
9
|
+
*/
|
|
10
|
+
import { createSession, getSessionRow } from "../db/sessions";
|
|
11
|
+
import { getAgent } from "../db/agents";
|
|
12
|
+
import { getSession } from "../db/sessions";
|
|
13
|
+
import { listEvents } from "../db/events";
|
|
14
|
+
import { appendEvent } from "./bus";
|
|
15
|
+
import { getActor } from "./actor";
|
|
16
|
+
import { runTurn } from "./driver";
|
|
17
|
+
import { nowMs } from "../util/clock";
|
|
18
|
+
import { ApiError } from "../errors";
|
|
19
|
+
|
|
20
|
+
const MAX_THREAD_DEPTH = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spawn a child agent session, run it to completion, and return the
|
|
24
|
+
* child's final agent.message text.
|
|
25
|
+
*/
|
|
26
|
+
export async function handleSpawnAgent(
|
|
27
|
+
parentSessionId: string,
|
|
28
|
+
agentId: string,
|
|
29
|
+
prompt: string,
|
|
30
|
+
parentDepth: number,
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
if (parentDepth >= MAX_THREAD_DEPTH) {
|
|
33
|
+
throw new ApiError(
|
|
34
|
+
400,
|
|
35
|
+
"invalid_request_error",
|
|
36
|
+
`thread depth limit reached (max ${MAX_THREAD_DEPTH})`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parentSession = getSession(parentSessionId);
|
|
41
|
+
if (!parentSession) {
|
|
42
|
+
throw new ApiError(404, "not_found_error", `parent session not found: ${parentSessionId}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const agent = getAgent(agentId);
|
|
46
|
+
if (!agent) {
|
|
47
|
+
throw new ApiError(404, "not_found_error", `agent not found: ${agentId}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Create child session with parent reference and incremented depth
|
|
51
|
+
const childSession = createSession({
|
|
52
|
+
agent_id: agent.id,
|
|
53
|
+
agent_version: agent.version,
|
|
54
|
+
environment_id: parentSession.environment_id,
|
|
55
|
+
title: `Thread from ${parentSessionId}`,
|
|
56
|
+
metadata: { parent_session_id: parentSessionId },
|
|
57
|
+
parent_session_id: parentSessionId,
|
|
58
|
+
thread_depth: parentDepth + 1,
|
|
59
|
+
vault_ids: parentSession.vault_ids,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Emit thread_started on parent
|
|
63
|
+
appendEvent(parentSessionId, {
|
|
64
|
+
type: "session.thread_started",
|
|
65
|
+
payload: { child_session_id: childSession.id, agent_id: agentId },
|
|
66
|
+
origin: "server",
|
|
67
|
+
processedAt: nowMs(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Spawn the child actor
|
|
71
|
+
getActor(childSession.id);
|
|
72
|
+
|
|
73
|
+
// Run the child turn
|
|
74
|
+
const eventId = `thread_${childSession.id}_${nowMs()}`;
|
|
75
|
+
await runTurn(childSession.id, [
|
|
76
|
+
{ kind: "text", eventId, text: prompt },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// Wait for completion: poll until session is idle
|
|
80
|
+
const maxWaitMs = 300_000; // 5 minutes
|
|
81
|
+
const pollIntervalMs = 500;
|
|
82
|
+
const startMs = nowMs();
|
|
83
|
+
let childRow = getSessionRow(childSession.id);
|
|
84
|
+
while (childRow && childRow.status === "running" && nowMs() - startMs < maxWaitMs) {
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
86
|
+
childRow = getSessionRow(childSession.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If timed out, interrupt and clean up the child
|
|
90
|
+
if (childRow && childRow.status === "running") {
|
|
91
|
+
const { interruptSession } = await import("./interrupt");
|
|
92
|
+
interruptSession(childSession.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Extract the last agent.message text from the child's events
|
|
96
|
+
let resultText = "";
|
|
97
|
+
const events = listEvents(childSession.id, { limit: 100, order: "desc" });
|
|
98
|
+
for (const evt of events) {
|
|
99
|
+
if (evt.type === "agent.message") {
|
|
100
|
+
const payload = JSON.parse(evt.payload_json) as {
|
|
101
|
+
content?: Array<{ type: string; text?: string }>;
|
|
102
|
+
};
|
|
103
|
+
const text = (payload.content ?? [])
|
|
104
|
+
.filter((b) => b.type === "text" && b.text)
|
|
105
|
+
.map((b) => b.text!)
|
|
106
|
+
.join("");
|
|
107
|
+
if (text) {
|
|
108
|
+
resultText = text;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Emit thread_completed on parent
|
|
115
|
+
appendEvent(parentSessionId, {
|
|
116
|
+
type: "session.thread_completed",
|
|
117
|
+
payload: {
|
|
118
|
+
child_session_id: childSession.id,
|
|
119
|
+
result: resultText || "(no response from sub-agent)",
|
|
120
|
+
},
|
|
121
|
+
origin: "server",
|
|
122
|
+
processedAt: nowMs(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return resultText || "(no response from sub-agent)";
|
|
126
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an agent's `tools` array into claude --allowed-tools /
|
|
3
|
+
* --disallowed-tools args plus the set of custom tool names.
|
|
4
|
+
*
|
|
5
|
+
* Built-in set matches the claude CLI's tool names (case-sensitive).
|
|
6
|
+
*/
|
|
7
|
+
import { BUILT_IN_TOOL_NAMES, type BuiltInToolName, type ToolConfig } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface ResolvedTools {
|
|
10
|
+
allowedTools: string[];
|
|
11
|
+
disallowedTools: string[];
|
|
12
|
+
customToolNames: Set<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveToolset(tools: ToolConfig[]): ResolvedTools {
|
|
16
|
+
const customToolNames = new Set<string>();
|
|
17
|
+
let builtInEnabled = new Set<BuiltInToolName>(BUILT_IN_TOOL_NAMES);
|
|
18
|
+
let hadAgentToolset = false;
|
|
19
|
+
|
|
20
|
+
for (const tool of tools) {
|
|
21
|
+
if (tool.type === "agent_toolset_20260401") {
|
|
22
|
+
hadAgentToolset = true;
|
|
23
|
+
const defaultEnabled = tool.default_config?.enabled ?? true;
|
|
24
|
+
if (!defaultEnabled) {
|
|
25
|
+
builtInEnabled = new Set();
|
|
26
|
+
}
|
|
27
|
+
for (const cfg of tool.configs ?? []) {
|
|
28
|
+
const name = cfg.name;
|
|
29
|
+
if (!BUILT_IN_TOOL_NAMES.includes(name as BuiltInToolName)) continue;
|
|
30
|
+
if (cfg.enabled === false) {
|
|
31
|
+
builtInEnabled.delete(name as BuiltInToolName);
|
|
32
|
+
} else if (cfg.enabled === true) {
|
|
33
|
+
builtInEnabled.add(name as BuiltInToolName);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} else if (tool.type === "custom") {
|
|
37
|
+
customToolNames.add(tool.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If no agent_toolset_20260401 was declared, start from empty built-ins.
|
|
42
|
+
if (!hadAgentToolset) {
|
|
43
|
+
builtInEnabled = new Set();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allowedTools = Array.from(builtInEnabled);
|
|
47
|
+
const disallowedTools = BUILT_IN_TOOL_NAMES.filter((n) => !builtInEnabled.has(n));
|
|
48
|
+
|
|
49
|
+
return { allowedTools, disallowedTools, customToolNames };
|
|
50
|
+
}
|
package/src/shutdown.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful shutdown on SIGTERM and SIGINT.
|
|
3
|
+
*
|
|
4
|
+
* Aborts all in-flight turn controllers, gives them up to 5s to emit their
|
|
5
|
+
* `session.status_idle{stop_reason:"interrupted"}` via the driver's normal
|
|
6
|
+
* abort path, then exits. Sessions that don't finish in time will be picked
|
|
7
|
+
* up by the next startup's stale-recovery path.
|
|
8
|
+
*
|
|
9
|
+
* Pattern from
|
|
10
|
+
*/
|
|
11
|
+
import { getRuntime } from "./state";
|
|
12
|
+
import { markStopping } from "./sessions/sweeper";
|
|
13
|
+
import { syncDb, closeDb } from "./db/client";
|
|
14
|
+
|
|
15
|
+
type GlobalShutdown = typeof globalThis & {
|
|
16
|
+
__caShutdownInstalled?: boolean;
|
|
17
|
+
__caSweeperHandle?: NodeJS.Timeout;
|
|
18
|
+
};
|
|
19
|
+
const g = globalThis as GlobalShutdown;
|
|
20
|
+
|
|
21
|
+
export function installShutdownHandlers(): void {
|
|
22
|
+
if (g.__caShutdownInstalled) return;
|
|
23
|
+
g.__caShutdownInstalled = true;
|
|
24
|
+
|
|
25
|
+
process.on("SIGTERM", () => {
|
|
26
|
+
void shutdown("SIGTERM");
|
|
27
|
+
});
|
|
28
|
+
process.on("SIGINT", () => {
|
|
29
|
+
void shutdown("SIGINT");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let shuttingDown = false;
|
|
34
|
+
|
|
35
|
+
async function shutdown(signal: string): Promise<void> {
|
|
36
|
+
if (shuttingDown) {
|
|
37
|
+
// Second signal — force exit immediately
|
|
38
|
+
console.log(`[shutdown] forced exit`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
shuttingDown = true;
|
|
42
|
+
|
|
43
|
+
const rt = getRuntime();
|
|
44
|
+
const inflight = rt.inFlightRuns.size;
|
|
45
|
+
|
|
46
|
+
if (inflight > 0) {
|
|
47
|
+
console.log(`[shutdown] stopping ${inflight} in-flight turn${inflight > 1 ? "s" : ""}...`);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(`[shutdown] shutting down...`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Tell the sweeper to stop starting new eviction work, then clear the
|
|
53
|
+
// interval. Any in-progress sweep finishes its current candidate and bails.
|
|
54
|
+
markStopping();
|
|
55
|
+
if (g.__caSweeperHandle) {
|
|
56
|
+
clearInterval(g.__caSweeperHandle);
|
|
57
|
+
g.__caSweeperHandle = undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const run of rt.inFlightRuns.values()) {
|
|
61
|
+
try {
|
|
62
|
+
run.controller.abort(new DOMException("shutting down", "AbortError"));
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Give drivers a moment to append their idle-interrupted events
|
|
69
|
+
if (inflight > 0) {
|
|
70
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Sync embedded replica to Turso and close the DB cleanly
|
|
74
|
+
syncDb();
|
|
75
|
+
closeDb();
|
|
76
|
+
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|