talon-agent 1.5.0 → 1.6.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 +1 -1
- package/src/__tests__/chat-settings.test.ts +20 -7
- package/src/__tests__/fuzz.test.ts +3 -0
- package/src/__tests__/reload-plugins.test.ts +11 -5
- package/src/backend/claude-sdk/constants.ts +63 -0
- package/src/backend/claude-sdk/handler.ts +236 -0
- package/src/backend/claude-sdk/index.ts +7 -556
- package/src/backend/claude-sdk/models.ts +216 -0
- package/src/backend/claude-sdk/options.ts +129 -0
- package/src/backend/claude-sdk/state.ts +59 -0
- package/src/backend/claude-sdk/stream.ts +221 -0
- package/src/backend/claude-sdk/warm.ts +89 -0
- package/src/bootstrap.ts +19 -5
- package/src/cli.ts +30 -15
- package/src/core/dream.ts +5 -17
- package/src/core/gateway-actions.ts +3 -12
- package/src/core/gateway.ts +5 -2
- package/src/core/heartbeat.ts +4 -17
- package/src/core/models.ts +149 -0
- package/src/core/types.ts +4 -0
- package/src/frontend/teams/index.ts +1 -3
- package/src/frontend/telegram/callbacks.ts +15 -27
- package/src/frontend/telegram/commands.ts +23 -28
- package/src/frontend/telegram/helpers.ts +13 -15
- package/src/frontend/telegram/index.ts +1 -1
- package/src/frontend/terminal/commands.ts +7 -4
- package/src/index.ts +2 -1
- package/src/storage/chat-settings.ts +5 -19
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude model discovery — queries the SDK for available models.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a throwaway SDK subprocess, calls supportedModels(), and
|
|
5
|
+
* registers the results in the global model registry. This is the
|
|
6
|
+
* only source of truth for available Claude models — if the SDK
|
|
7
|
+
* fails to provide models, initialization is aborted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import { registerModels, clearModelsByProvider } from "../../core/models.js";
|
|
12
|
+
import type { ModelInfo } from "../../core/models.js";
|
|
13
|
+
import { log, logError } from "../../util/log.js";
|
|
14
|
+
|
|
15
|
+
// ── Tier / fallback inference ───────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Infer tier from model ID. */
|
|
18
|
+
function inferTier(modelId: string): ModelInfo["tier"] {
|
|
19
|
+
if (modelId.includes("opus")) return "premium";
|
|
20
|
+
if (modelId.includes("haiku")) return "economy";
|
|
21
|
+
return "balanced";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build short aliases from a model ID like "claude-sonnet-4-6". */
|
|
25
|
+
function buildAliases(modelId: string): string[] {
|
|
26
|
+
const aliases: string[] = [];
|
|
27
|
+
const match = modelId.match(/claude-(\w+)-(.+)/);
|
|
28
|
+
if (match) {
|
|
29
|
+
const family = match[1];
|
|
30
|
+
const version = match[2];
|
|
31
|
+
aliases.push(family);
|
|
32
|
+
aliases.push(`${family}-${version}`);
|
|
33
|
+
aliases.push(`${family}-${version.replace(/-/g, ".")}`);
|
|
34
|
+
}
|
|
35
|
+
return aliases;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── SDK → registry conversion ───────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert SDK ModelInfo to our registry format.
|
|
42
|
+
* Sorts by tier and builds fallback chains automatically.
|
|
43
|
+
*/
|
|
44
|
+
function convertSdkModels(
|
|
45
|
+
sdkModels: Array<{
|
|
46
|
+
value: string;
|
|
47
|
+
displayName: string;
|
|
48
|
+
description: string;
|
|
49
|
+
}>,
|
|
50
|
+
): ModelInfo[] {
|
|
51
|
+
// Filter out SDK artifacts:
|
|
52
|
+
// - [1m] variants: we add this suffix ourselves in options.ts
|
|
53
|
+
// - "default" pseudo-model: not a real model, just an alias for the default
|
|
54
|
+
// - any model that doesn't start with "claude-": not an Anthropic model
|
|
55
|
+
const filtered = sdkModels.filter(
|
|
56
|
+
(m) => m.value.startsWith("claude-") && !m.value.includes("["),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const models: ModelInfo[] = filtered.map((m) => ({
|
|
60
|
+
id: m.value,
|
|
61
|
+
displayName: m.displayName,
|
|
62
|
+
description: m.description,
|
|
63
|
+
aliases: buildAliases(m.value),
|
|
64
|
+
provider: "anthropic",
|
|
65
|
+
capabilities: {
|
|
66
|
+
supports1mContext: !m.value.includes("haiku"),
|
|
67
|
+
},
|
|
68
|
+
tier: inferTier(m.value),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const tierOrder = { premium: 0, balanced: 1, economy: 2 };
|
|
72
|
+
models.sort((a, b) => tierOrder[a.tier] - tierOrder[b.tier]);
|
|
73
|
+
|
|
74
|
+
// Fallback chain: each model falls back to the first model in the next lower tier
|
|
75
|
+
for (const model of models) {
|
|
76
|
+
if (model.fallback) continue;
|
|
77
|
+
const nextTier = models.find(
|
|
78
|
+
(m) => tierOrder[m.tier] > tierOrder[model.tier],
|
|
79
|
+
);
|
|
80
|
+
if (nextTier) model.fallback = nextTier.id;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return models;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Discover available models from the Claude Agent SDK and register them.
|
|
90
|
+
*
|
|
91
|
+
* Spawns a throwaway SDK subprocess, calls supportedModels(), converts the
|
|
92
|
+
* results to our registry format, and registers them. Throws on failure —
|
|
93
|
+
* if the SDK can't provide models, Talon cannot function.
|
|
94
|
+
*/
|
|
95
|
+
export async function registerClaudeModels(sdkOptions: {
|
|
96
|
+
model: string;
|
|
97
|
+
cwd?: string;
|
|
98
|
+
permissionMode?: string;
|
|
99
|
+
allowDangerouslySkipPermissions?: boolean;
|
|
100
|
+
pathToClaudeCodeExecutable?: string;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
const abort = new AbortController();
|
|
103
|
+
let drainPromise: Promise<void> | undefined;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const neverYield = async function* (): AsyncGenerator<never> {
|
|
107
|
+
await new Promise<never>((_, reject) => {
|
|
108
|
+
abort.signal.addEventListener("abort", () =>
|
|
109
|
+
reject(new Error("aborted")),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const q = query({
|
|
115
|
+
prompt: neverYield(),
|
|
116
|
+
options: {
|
|
117
|
+
...sdkOptions,
|
|
118
|
+
abortController: abort,
|
|
119
|
+
} as Parameters<typeof query>[0]["options"],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
drainPromise = (async () => {
|
|
123
|
+
try {
|
|
124
|
+
for await (const _ of q) {
|
|
125
|
+
/* discard */
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
/* expected on abort */
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
133
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
134
|
+
timeoutId = setTimeout(
|
|
135
|
+
() => reject(new Error("model discovery timed out after 15s")),
|
|
136
|
+
15_000,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let sdkModels: Array<{
|
|
141
|
+
value: string;
|
|
142
|
+
displayName: string;
|
|
143
|
+
description: string;
|
|
144
|
+
}>;
|
|
145
|
+
try {
|
|
146
|
+
sdkModels = await Promise.race([q.supportedModels(), timeout]);
|
|
147
|
+
} finally {
|
|
148
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sdkModels.length === 0) {
|
|
152
|
+
throw new Error("SDK returned empty model list");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const models = convertSdkModels(sdkModels);
|
|
156
|
+
clearModelsByProvider("anthropic");
|
|
157
|
+
registerModels(models);
|
|
158
|
+
log(
|
|
159
|
+
"agent",
|
|
160
|
+
`Discovered ${models.length} models from SDK: ${models.map((m) => m.id).join(", ")}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
abort.abort();
|
|
164
|
+
await drainPromise;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
abort.abort();
|
|
167
|
+
if (drainPromise) await drainPromise.catch(() => {});
|
|
168
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
+
logError("agent", `Fatal: model discovery failed — ${msg}`);
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Claude SDK model discovery failed: ${msg}. ` +
|
|
172
|
+
`Check that Claude Code is installed and your API key is valid.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Register models from a static list. For use in tests and the CLI setup
|
|
179
|
+
* wizard where the SDK subprocess is not available.
|
|
180
|
+
*/
|
|
181
|
+
export function registerClaudeModelsStatic(models: ModelInfo[]): void {
|
|
182
|
+
registerModels(models);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Default model definitions for CLI setup wizard and tests. */
|
|
186
|
+
export const CLAUDE_MODELS_STATIC: ModelInfo[] = [
|
|
187
|
+
{
|
|
188
|
+
id: "claude-opus-4-6",
|
|
189
|
+
displayName: "Opus 4.6",
|
|
190
|
+
description: "smartest",
|
|
191
|
+
aliases: ["opus", "opus-4.6", "opus-4-6"],
|
|
192
|
+
provider: "anthropic",
|
|
193
|
+
capabilities: { supports1mContext: true },
|
|
194
|
+
tier: "premium",
|
|
195
|
+
fallback: "claude-sonnet-4-6",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "claude-sonnet-4-6",
|
|
199
|
+
displayName: "Sonnet 4.6",
|
|
200
|
+
description: "fast, balanced",
|
|
201
|
+
aliases: ["sonnet", "sonnet-4.6", "sonnet-4-6"],
|
|
202
|
+
provider: "anthropic",
|
|
203
|
+
capabilities: { supports1mContext: true },
|
|
204
|
+
tier: "balanced",
|
|
205
|
+
fallback: "claude-haiku-4-5",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "claude-haiku-4-5",
|
|
209
|
+
displayName: "Haiku 4.5",
|
|
210
|
+
description: "fastest, cheapest",
|
|
211
|
+
aliases: ["haiku", "haiku-4.5", "haiku-4-5"],
|
|
212
|
+
provider: "anthropic",
|
|
213
|
+
capabilities: { supports1mContext: false },
|
|
214
|
+
tier: "economy",
|
|
215
|
+
},
|
|
216
|
+
];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK options builder — constructs the configuration object for query() calls.
|
|
3
|
+
*
|
|
4
|
+
* Translates per-chat settings (model, effort) and global config (plugins,
|
|
5
|
+
* MCP servers, system prompt) into the Options shape expected by the SDK.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
|
10
|
+
import { getSession } from "../../storage/sessions.js";
|
|
11
|
+
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
12
|
+
import { getPluginMcpServers } from "../../core/plugin.js";
|
|
13
|
+
import { supports1mContext } from "../../core/models.js";
|
|
14
|
+
import { getConfig, getBridgePort } from "./state.js";
|
|
15
|
+
import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
|
|
16
|
+
|
|
17
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type BuildSdkOptionsResult = {
|
|
20
|
+
options: Options;
|
|
21
|
+
activeModel: string;
|
|
22
|
+
session: ReturnType<typeof getSession>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── MCP server construction ─────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the MCP servers map for a chat query.
|
|
29
|
+
* Includes frontend-specific tool servers and Brave Search, if configured.
|
|
30
|
+
*/
|
|
31
|
+
function buildMcpServers(
|
|
32
|
+
chatId: string,
|
|
33
|
+
): Record<
|
|
34
|
+
string,
|
|
35
|
+
{ command: string; args: string[]; env: Record<string, string> }
|
|
36
|
+
> {
|
|
37
|
+
const config = getConfig();
|
|
38
|
+
const bridgeUrl = `http://127.0.0.1:${getBridgePort()}`;
|
|
39
|
+
|
|
40
|
+
const tsxImport = resolve(
|
|
41
|
+
import.meta.dirname ?? ".",
|
|
42
|
+
"../../../node_modules/tsx/dist/esm/index.mjs",
|
|
43
|
+
);
|
|
44
|
+
const mcpServerPath = resolve(
|
|
45
|
+
import.meta.dirname ?? ".",
|
|
46
|
+
"../../core/tools/mcp-server.ts",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Frontend-specific MCP tool servers (one per non-terminal frontend)
|
|
50
|
+
const allFrontends = Array.isArray(config.frontend)
|
|
51
|
+
? config.frontend
|
|
52
|
+
: [config.frontend];
|
|
53
|
+
const frontends = allFrontends.filter((f) => f !== "terminal");
|
|
54
|
+
|
|
55
|
+
const servers: Record<
|
|
56
|
+
string,
|
|
57
|
+
{ command: string; args: string[]; env: Record<string, string> }
|
|
58
|
+
> = {};
|
|
59
|
+
|
|
60
|
+
for (const frontend of frontends) {
|
|
61
|
+
const serverName = `${frontend}-tools`;
|
|
62
|
+
const mcpEnv = {
|
|
63
|
+
TALON_BRIDGE_URL: bridgeUrl,
|
|
64
|
+
TALON_CHAT_ID: chatId,
|
|
65
|
+
TALON_FRONTEND: frontend,
|
|
66
|
+
};
|
|
67
|
+
servers[serverName] = {
|
|
68
|
+
command: process.platform === "win32" ? "npx" : "node",
|
|
69
|
+
args:
|
|
70
|
+
process.platform === "win32"
|
|
71
|
+
? ["tsx", mcpServerPath]
|
|
72
|
+
: ["--import", tsxImport, mcpServerPath],
|
|
73
|
+
env: mcpEnv,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Brave Search MCP server (if configured)
|
|
78
|
+
if (config.braveApiKey) {
|
|
79
|
+
servers["brave-search"] = {
|
|
80
|
+
command: resolve(
|
|
81
|
+
import.meta.dirname ?? ".",
|
|
82
|
+
"../../../node_modules/.bin/brave-search-mcp-server",
|
|
83
|
+
),
|
|
84
|
+
args: [],
|
|
85
|
+
env: { BRAVE_API_KEY: config.braveApiKey },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return servers;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Options builder ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
|
|
95
|
+
const config = getConfig();
|
|
96
|
+
const chatSettings = getChatSettings(chatId);
|
|
97
|
+
const activeModel = chatSettings.model ?? config.model;
|
|
98
|
+
const activeEffort = chatSettings.effort ?? "adaptive";
|
|
99
|
+
|
|
100
|
+
const thinkingConfig = EFFORT_MAP[activeEffort] ?? {
|
|
101
|
+
thinking: { type: "adaptive" as const },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const canUse1m =
|
|
105
|
+
supports1mContext(activeModel) && !activeModel.includes("[1m]");
|
|
106
|
+
const sdkModel = canUse1m ? `${activeModel}[1m]` : activeModel;
|
|
107
|
+
|
|
108
|
+
const session = getSession(chatId);
|
|
109
|
+
|
|
110
|
+
const options: Options = {
|
|
111
|
+
model: sdkModel,
|
|
112
|
+
systemPrompt: config.systemPrompt,
|
|
113
|
+
cwd: config.workspace,
|
|
114
|
+
permissionMode: "bypassPermissions",
|
|
115
|
+
allowDangerouslySkipPermissions: true,
|
|
116
|
+
...(config.claudeBinary
|
|
117
|
+
? { pathToClaudeCodeExecutable: config.claudeBinary }
|
|
118
|
+
: {}),
|
|
119
|
+
disallowedTools: [...DISALLOWED_TOOLS_CHAT],
|
|
120
|
+
...thinkingConfig,
|
|
121
|
+
mcpServers: {
|
|
122
|
+
...buildMcpServers(chatId),
|
|
123
|
+
...getPluginMcpServers(`http://127.0.0.1:${getBridgePort()}`, chatId),
|
|
124
|
+
},
|
|
125
|
+
...(session.sessionId ? { resume: session.sessionId } : {}),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return { options, activeModel, session };
|
|
129
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level state for the Claude SDK backend.
|
|
3
|
+
*
|
|
4
|
+
* Owns the mutable config and bridge port references and exposes
|
|
5
|
+
* initialization functions + internal getters for sibling modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
9
|
+
import { registerClaudeModels } from "./models.js";
|
|
10
|
+
|
|
11
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
let config: TalonConfig | undefined;
|
|
14
|
+
let bridgePortFn: () => number = () => 19876;
|
|
15
|
+
|
|
16
|
+
// ── Public API (re-exported from barrel) ────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export async function initAgent(
|
|
19
|
+
cfg: TalonConfig,
|
|
20
|
+
getBridgePort?: () => number,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
config = cfg;
|
|
23
|
+
if (getBridgePort) bridgePortFn = getBridgePort;
|
|
24
|
+
|
|
25
|
+
// The Agent SDK spawns an embedded Claude Code subprocess.
|
|
26
|
+
// If CLAUDECODE is set (e.g. running from a Claude Code terminal),
|
|
27
|
+
// the subprocess refuses to start with a nested-session error that
|
|
28
|
+
// gets swallowed — causing an infinite hang on Windows.
|
|
29
|
+
delete process.env.CLAUDECODE;
|
|
30
|
+
|
|
31
|
+
// Discover available models from the SDK — fatal if this fails
|
|
32
|
+
await registerClaudeModels({
|
|
33
|
+
model: cfg.model,
|
|
34
|
+
cwd: cfg.workspace,
|
|
35
|
+
permissionMode: "bypassPermissions",
|
|
36
|
+
allowDangerouslySkipPermissions: true,
|
|
37
|
+
...(cfg.claudeBinary
|
|
38
|
+
? { pathToClaudeCodeExecutable: cfg.claudeBinary }
|
|
39
|
+
: {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Update the system prompt on the live config. Used by plugin hot-reload
|
|
44
|
+
* so the next message picks up new plugin tool descriptions. */
|
|
45
|
+
export function updateSystemPrompt(prompt: string): void {
|
|
46
|
+
if (config) config.systemPrompt = prompt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Internal getters (used by sibling modules, NOT re-exported) ─────────────
|
|
50
|
+
|
|
51
|
+
export function getConfig(): TalonConfig {
|
|
52
|
+
if (!config)
|
|
53
|
+
throw new Error("Agent not initialized. Call initAgent() first.");
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getBridgePort(): number {
|
|
58
|
+
return bridgePortFn();
|
|
59
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed stream processing helpers for SDK messages.
|
|
3
|
+
*
|
|
4
|
+
* Each function operates on a properly narrowed SDK message type —
|
|
5
|
+
* no Record<string, unknown> casts. The StreamState accumulator
|
|
6
|
+
* replaces the scattered local variables from the original handler.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
SDKMessage,
|
|
11
|
+
SDKSystemMessage,
|
|
12
|
+
SDKPartialAssistantMessage,
|
|
13
|
+
SDKAssistantMessage,
|
|
14
|
+
SDKResultMessage,
|
|
15
|
+
ModelUsage,
|
|
16
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
17
|
+
import type { BetaRawContentBlockDeltaEvent } from "@anthropic-ai/sdk/resources/beta/messages/messages.mjs";
|
|
18
|
+
import { STREAM_INTERVAL } from "./constants.js";
|
|
19
|
+
import { log } from "../../util/log.js";
|
|
20
|
+
|
|
21
|
+
// ── Stream state accumulator ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Mutable state accumulated while iterating the SDK message stream. */
|
|
24
|
+
export type StreamState = {
|
|
25
|
+
currentBlockText: string;
|
|
26
|
+
allResponseText: string;
|
|
27
|
+
newSessionId: string | undefined;
|
|
28
|
+
toolCalls: number;
|
|
29
|
+
contextTokens: number;
|
|
30
|
+
contextWindow: number | undefined;
|
|
31
|
+
numApiCalls: number;
|
|
32
|
+
sdkInputTokens: number;
|
|
33
|
+
sdkOutputTokens: number;
|
|
34
|
+
sdkCacheRead: number;
|
|
35
|
+
sdkCacheWrite: number;
|
|
36
|
+
lastStreamUpdate: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createStreamState(): StreamState {
|
|
40
|
+
return {
|
|
41
|
+
currentBlockText: "",
|
|
42
|
+
allResponseText: "",
|
|
43
|
+
newSessionId: undefined,
|
|
44
|
+
toolCalls: 0,
|
|
45
|
+
contextTokens: 0,
|
|
46
|
+
contextWindow: undefined,
|
|
47
|
+
numApiCalls: 0,
|
|
48
|
+
sdkInputTokens: 0,
|
|
49
|
+
sdkOutputTokens: 0,
|
|
50
|
+
sdkCacheRead: 0,
|
|
51
|
+
sdkCacheWrite: 0,
|
|
52
|
+
lastStreamUpdate: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Type guards ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function isSystemInit(msg: SDKMessage): msg is SDKSystemMessage {
|
|
59
|
+
return msg.type === "system" && msg.subtype === "init";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isStreamEvent(
|
|
63
|
+
msg: SDKMessage,
|
|
64
|
+
): msg is SDKPartialAssistantMessage {
|
|
65
|
+
return msg.type === "stream_event";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isAssistant(msg: SDKMessage): msg is SDKAssistantMessage {
|
|
69
|
+
return msg.type === "assistant";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isResult(msg: SDKMessage): msg is SDKResultMessage {
|
|
73
|
+
return msg.type === "result";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Message processors ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Process a streaming delta event — accumulates text and fires throttled
|
|
80
|
+
* callbacks for thinking and text phases.
|
|
81
|
+
*/
|
|
82
|
+
export function processStreamDelta(
|
|
83
|
+
msg: SDKPartialAssistantMessage,
|
|
84
|
+
state: StreamState,
|
|
85
|
+
onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void,
|
|
86
|
+
): void {
|
|
87
|
+
if (!onStreamDelta) return;
|
|
88
|
+
|
|
89
|
+
const event = msg.event;
|
|
90
|
+
if (event.type !== "content_block_delta") return;
|
|
91
|
+
|
|
92
|
+
const deltaEvent = event as BetaRawContentBlockDeltaEvent;
|
|
93
|
+
const delta = deltaEvent.delta;
|
|
94
|
+
|
|
95
|
+
if (delta.type === "thinking_delta") {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (now - state.lastStreamUpdate >= STREAM_INTERVAL) {
|
|
98
|
+
state.lastStreamUpdate = now;
|
|
99
|
+
onStreamDelta(state.currentBlockText, "thinking");
|
|
100
|
+
}
|
|
101
|
+
} else if (delta.type === "text_delta") {
|
|
102
|
+
state.currentBlockText += delta.text;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (now - state.lastStreamUpdate >= STREAM_INTERVAL) {
|
|
105
|
+
state.lastStreamUpdate = now;
|
|
106
|
+
onStreamDelta(state.currentBlockText, "text");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A tool call extracted from an assistant message. */
|
|
112
|
+
export type ToolCall = {
|
|
113
|
+
name: string;
|
|
114
|
+
input: Record<string, unknown>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Result of processing an assistant message. */
|
|
118
|
+
export type AssistantResult = {
|
|
119
|
+
/** Text segments accumulated before tool calls, each to be sent as a progress message. */
|
|
120
|
+
progressTexts: string[];
|
|
121
|
+
/** Tool calls found in the message. */
|
|
122
|
+
tools: ToolCall[];
|
|
123
|
+
/** Trailing text after all tool calls (or the full text if no tool calls). */
|
|
124
|
+
trailingText: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Process a complete assistant message — extracts text blocks and tool calls.
|
|
129
|
+
* Uses the typed BetaContentBlock discriminated union.
|
|
130
|
+
*
|
|
131
|
+
* When multiple tool_use blocks appear in the same message with text before
|
|
132
|
+
* each, every text segment is captured in progressTexts so the handler can
|
|
133
|
+
* emit them all in order.
|
|
134
|
+
*/
|
|
135
|
+
export function processAssistantMessage(
|
|
136
|
+
msg: SDKAssistantMessage,
|
|
137
|
+
state: StreamState,
|
|
138
|
+
): AssistantResult {
|
|
139
|
+
const content = msg.message.content;
|
|
140
|
+
const tools: ToolCall[] = [];
|
|
141
|
+
const progressTexts: string[] = [];
|
|
142
|
+
let blockText = "";
|
|
143
|
+
|
|
144
|
+
for (const block of content) {
|
|
145
|
+
if (block.type === "text") {
|
|
146
|
+
blockText += block.text;
|
|
147
|
+
}
|
|
148
|
+
if (block.type === "tool_use") {
|
|
149
|
+
state.toolCalls++;
|
|
150
|
+
const input =
|
|
151
|
+
typeof block.input === "object" && block.input !== null
|
|
152
|
+
? (block.input as Record<string, unknown>)
|
|
153
|
+
: {};
|
|
154
|
+
tools.push({ name: block.name, input });
|
|
155
|
+
// Text before this tool call is a progress message
|
|
156
|
+
if (blockText.trim()) {
|
|
157
|
+
progressTexts.push(blockText.trim());
|
|
158
|
+
state.allResponseText += blockText;
|
|
159
|
+
blockText = "";
|
|
160
|
+
state.currentBlockText = "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Remaining text after all tool calls (or if no tool calls)
|
|
166
|
+
const trailingText = blockText.trim() ? blockText : "";
|
|
167
|
+
if (trailingText) {
|
|
168
|
+
state.currentBlockText = blockText;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { progressTexts, tools, trailingText };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Process the final result message — extracts token counts, context info,
|
|
176
|
+
* and API call counts from the typed SDK result.
|
|
177
|
+
*/
|
|
178
|
+
export function processResultMessage(
|
|
179
|
+
msg: SDKResultMessage,
|
|
180
|
+
state: StreamState,
|
|
181
|
+
): void {
|
|
182
|
+
state.numApiCalls = msg.num_turns ?? 0;
|
|
183
|
+
|
|
184
|
+
// Context fill from last API iteration
|
|
185
|
+
const usage = msg.usage;
|
|
186
|
+
if (usage && Array.isArray(usage.iterations) && usage.iterations.length > 0) {
|
|
187
|
+
const last = usage.iterations[usage.iterations.length - 1];
|
|
188
|
+
state.contextTokens =
|
|
189
|
+
(last.input_tokens ?? 0) +
|
|
190
|
+
(last.cache_read_input_tokens ?? 0) +
|
|
191
|
+
(last.cache_creation_input_tokens ?? 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Token counts and context window from SDK modelUsage (aggregated per model)
|
|
195
|
+
const modelUsage: Record<string, ModelUsage> = msg.modelUsage;
|
|
196
|
+
for (const mu of Object.values(modelUsage)) {
|
|
197
|
+
state.sdkInputTokens += mu.inputTokens ?? 0;
|
|
198
|
+
state.sdkOutputTokens += mu.outputTokens ?? 0;
|
|
199
|
+
state.sdkCacheRead += mu.cacheReadInputTokens ?? 0;
|
|
200
|
+
state.sdkCacheWrite += mu.cacheCreationInputTokens ?? 0;
|
|
201
|
+
if (mu.contextWindow > 0 && state.contextWindow === undefined) {
|
|
202
|
+
state.contextWindow = mu.contextWindow;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
log(
|
|
207
|
+
"agent",
|
|
208
|
+
`SDK result: modelUsage=${JSON.stringify(modelUsage)}, contextWindow=${state.contextWindow}, contextTokens=${state.contextTokens}, numApiCalls=${state.numApiCalls}`,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Fallback: if no text was captured via streaming or assistant messages,
|
|
212
|
+
// pull from the result string (available on success results).
|
|
213
|
+
if (
|
|
214
|
+
!state.allResponseText &&
|
|
215
|
+
!state.currentBlockText &&
|
|
216
|
+
"result" in msg &&
|
|
217
|
+
typeof msg.result === "string"
|
|
218
|
+
) {
|
|
219
|
+
state.currentBlockText = msg.result;
|
|
220
|
+
}
|
|
221
|
+
}
|