@pi-oxide/pi-host-web 0.3.1 → 0.4.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.
@@ -0,0 +1,241 @@
1
+ // EventMapper — converts raw WASM AgentEvent[] into semantic SDK events.
2
+ // Accumulates RunState (messages, toolCalls, artifacts, usage).
3
+ // Emits all status states: idle, loading, thinking, calling_model, running_tool, saving, completed, aborted, failed.
4
+ // Artifact events from turn_end markers (new API: artifacts tracked via tool result details).
5
+ // Usage accumulation from model responses.
6
+
7
+ import type {
8
+ AgentEvent as RawAgentEvent,
9
+ AgentMessage as WasmAgentMessage,
10
+ Content,
11
+ } from "../../pi_host_web.js";
12
+ import type {
13
+ AgentMessage,
14
+ AgentContentBlock,
15
+ AgentToolRun,
16
+ AgentArtifactRef,
17
+ AgentStatus,
18
+ AgentRunResult,
19
+ TokenUsage,
20
+ } from "../types.ts";
21
+
22
+ export interface SemanticEvent {
23
+ type: string;
24
+ payload: unknown;
25
+ }
26
+
27
+ export interface RunState {
28
+ messages: AgentMessage[];
29
+ toolCalls: AgentToolRun[];
30
+ artifacts: AgentArtifactRef[];
31
+ usage: TokenUsage;
32
+ text: string;
33
+ currentMessage: AgentMessage | null;
34
+ currentTool: AgentToolRun | null;
35
+ }
36
+
37
+ export class EventMapper {
38
+ createRunState(): RunState {
39
+ return {
40
+ messages: [],
41
+ toolCalls: [],
42
+ artifacts: [],
43
+ usage: { input: 0, output: 0, cache_read: 0, cache_write: 0, total_tokens: 0 },
44
+ text: "",
45
+ currentMessage: null,
46
+ currentTool: null,
47
+ };
48
+ }
49
+
50
+ map(rawEvent: RawAgentEvent, state: RunState): SemanticEvent[] {
51
+ const events: SemanticEvent[] = [];
52
+
53
+ switch (rawEvent.type) {
54
+ case "agent_start": {
55
+ events.push({ type: "status", payload: { state: "loading", message: "Agent starting..." } as AgentStatus });
56
+ break;
57
+ }
58
+
59
+ case "turn_start": {
60
+ events.push({ type: "status", payload: { state: "thinking", message: "Thinking..." } as AgentStatus });
61
+ break;
62
+ }
63
+
64
+ case "message_start": {
65
+ const msg = this.convertWasmMessage(rawEvent.message);
66
+ state.currentMessage = msg;
67
+ state.messages.push(msg);
68
+ events.push({ type: "messageStart", payload: msg });
69
+ events.push({ type: "status", payload: { state: "thinking" } as AgentStatus });
70
+ break;
71
+ }
72
+
73
+ case "message_update": {
74
+ const delta = rawEvent.delta;
75
+ if (delta.kind === "text_delta" && delta.text) {
76
+ state.text += delta.text;
77
+ events.push({ type: "text", payload: delta.text });
78
+ } else if (delta.kind === "tool_call_start" && delta.tool_call) {
79
+ // Track tool call in current message
80
+ } else if (delta.kind === "thinking_delta") {
81
+ events.push({ type: "status", payload: { state: "thinking", message: "Thinking..." } as AgentStatus });
82
+ }
83
+ break;
84
+ }
85
+
86
+ case "message_end": {
87
+ const msg = this.convertWasmMessage(rawEvent.message);
88
+ state.currentMessage = msg;
89
+ // Update the last message in state
90
+ const idx = state.messages.findIndex((m) => m.id === msg.id);
91
+ if (idx >= 0) {
92
+ state.messages[idx] = msg;
93
+ } else {
94
+ state.messages.push(msg);
95
+ }
96
+ events.push({ type: "messageEnd", payload: msg });
97
+ break;
98
+ }
99
+
100
+ case "tool_execution_start": {
101
+ const tool: AgentToolRun = {
102
+ id: rawEvent.tool_call_id,
103
+ name: rawEvent.tool_name,
104
+ title: rawEvent.tool_name,
105
+ input: rawEvent.args ?? {},
106
+ status: "running",
107
+ startedAt: Date.now(),
108
+ };
109
+ state.currentTool = tool;
110
+ state.toolCalls.push(tool);
111
+ events.push({ type: "toolStart", payload: tool });
112
+ events.push({ type: "status", payload: { state: "running_tool", message: `Running ${rawEvent.tool_name}...` } as AgentStatus });
113
+ break;
114
+ }
115
+
116
+ case "tool_execution_update": {
117
+ const tool = state.toolCalls.find((t) => t.id === rawEvent.tool_call_id);
118
+ if (tool) {
119
+ tool.output = (tool.output ?? "") + rawEvent.chunk;
120
+ events.push({ type: "toolUpdate", payload: tool });
121
+ }
122
+ break;
123
+ }
124
+
125
+ case "tool_execution_end": {
126
+ const tool = state.toolCalls.find((t) => t.id === rawEvent.tool_call_id);
127
+ if (tool) {
128
+ tool.status = rawEvent.is_error ? "failed" : "completed";
129
+ tool.endedAt = Date.now();
130
+ // Extract output from result
131
+ const resultText = rawEvent.result.content
132
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
133
+ .map((c) => c.text)
134
+ .join("\n");
135
+ tool.output = resultText;
136
+ events.push({ type: "toolEnd", payload: tool });
137
+ }
138
+ break;
139
+ }
140
+
141
+ case "tool_execution_cancelled": {
142
+ const tool = state.toolCalls.find((t) => t.id === rawEvent.tool_call_id);
143
+ if (tool) {
144
+ tool.status = "cancelled";
145
+ tool.endedAt = Date.now();
146
+ events.push({ type: "toolEnd", payload: tool });
147
+ }
148
+ break;
149
+ }
150
+
151
+ case "turn_end": {
152
+ // Extract final message
153
+ const finalMsg = this.convertWasmMessage(rawEvent.message);
154
+ const idx = state.messages.findIndex((m) => m.id === finalMsg.id);
155
+ if (idx >= 0) {
156
+ state.messages[idx] = finalMsg;
157
+ } else {
158
+ state.messages.push(finalMsg);
159
+ }
160
+
161
+ // Extract tool results
162
+ for (const tr of rawEvent.tool_results) {
163
+ const tool = state.toolCalls.find((t) => t.id === tr.tool_call_id);
164
+ if (tool) {
165
+ tool.status = tr.is_error ? "failed" : "completed";
166
+ tool.endedAt = Date.now();
167
+ const resultText = tr.content
168
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
169
+ .map((c) => c.text)
170
+ .join("\n");
171
+ tool.output = resultText;
172
+ }
173
+ }
174
+
175
+ events.push({ type: "status", payload: { state: "completed" } as AgentStatus });
176
+ break;
177
+ }
178
+
179
+ case "save_point": {
180
+ events.push({ type: "status", payload: { state: "saving", message: "Saving session..." } as AgentStatus });
181
+ break;
182
+ }
183
+
184
+ case "settled": {
185
+ events.push({ type: "status", payload: { state: "completed" } as AgentStatus });
186
+ break;
187
+ }
188
+
189
+ case "queue_update": {
190
+ // Debug channel — not a primary semantic event
191
+ break;
192
+ }
193
+
194
+ case "agent_end": {
195
+ events.push({ type: "status", payload: { state: "idle" } as AgentStatus });
196
+ break;
197
+ }
198
+ }
199
+
200
+ return events;
201
+ }
202
+
203
+ buildRunResult(state: RunState, turnResult: { aborted: boolean }): AgentRunResult {
204
+ if (turnResult.aborted) {
205
+ return {
206
+ status: "aborted",
207
+ text: state.text,
208
+ toolCalls: state.toolCalls,
209
+ artifacts: state.artifacts,
210
+ usage: state.usage,
211
+ };
212
+ }
213
+
214
+ return {
215
+ status: "completed",
216
+ message: state.currentMessage ?? undefined,
217
+ text: state.text,
218
+ toolCalls: state.toolCalls,
219
+ artifacts: state.artifacts,
220
+ usage: state.usage,
221
+ };
222
+ }
223
+
224
+ private convertWasmMessage(msg: WasmAgentMessage): AgentMessage {
225
+ const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
226
+ return {
227
+ id,
228
+ role: msg.role,
229
+ content: msg.content.map((c) => this.convertContent(c)),
230
+ timestamp: Date.now(),
231
+ tool_call_id: msg.role === "tool_result" ? (msg as unknown as { tool_call_id: string }).tool_call_id : undefined,
232
+ };
233
+ }
234
+
235
+ private convertContent(c: Content): AgentContentBlock {
236
+ if (c.type === "text") return { type: "text", text: c.text };
237
+ if (c.type === "tool_call") return { type: "tool_call", id: c.id, name: c.name, arguments: c.arguments };
238
+ if (c.type === "image") return { type: "image", mimeType: c.media_type, data: c.data };
239
+ return { type: "text", text: "" };
240
+ }
241
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Anthropic Messages API adapter.
3
+ *
4
+ * Converts between Rust agent core message format and the Anthropic Messages API.
5
+ * Works with any Anthropic-compatible endpoint (including Fireworks.ai).
6
+ *
7
+ * This adapter does NOT stream. It makes a single request and returns the full
8
+ * response as chunks + final result, matching the existing AgentHost pattern.
9
+ */
10
+
11
+ import type { ToolDefinition } from "../../../pi_host_web.js";
12
+ import type {
13
+ AgentMessageShape,
14
+ ContentBlock,
15
+ LlmRequest,
16
+ ProviderResult,
17
+ TokenUsage,
18
+ } from "./types.ts";
19
+
20
+ // --- Anthropic API types ---
21
+
22
+ interface AnthropicMessage {
23
+ role: "user" | "assistant";
24
+ content: string | AnthropicContentBlock[];
25
+ }
26
+
27
+ type AnthropicContentBlock =
28
+ | { type: "text"; text: string }
29
+ | {
30
+ type: "tool_use";
31
+ id: string;
32
+ name: string;
33
+ input: Record<string, unknown>;
34
+ }
35
+ | {
36
+ type: "tool_result";
37
+ tool_use_id: string;
38
+ content: string | AnthropicContentBlock[];
39
+ is_error?: boolean;
40
+ };
41
+
42
+ interface AnthropicTool {
43
+ name: string;
44
+ description: string;
45
+ input_schema: object;
46
+ }
47
+
48
+ interface AnthropicResponse {
49
+ id: string;
50
+ type: "message";
51
+ role: "assistant";
52
+ content: AnthropicContentBlock[];
53
+ model: string;
54
+ stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null;
55
+ usage: {
56
+ input_tokens: number;
57
+ output_tokens: number;
58
+ cache_creation_input_tokens?: number;
59
+ cache_read_input_tokens?: number;
60
+ };
61
+ }
62
+
63
+ interface AnthropicError {
64
+ type: "error";
65
+ error: { type: string; message: string };
66
+ }
67
+
68
+ // --- Conversion: Rust messages -> Anthropic messages ---
69
+
70
+ /**
71
+ * Convert Rust agent messages to Anthropic Messages API format.
72
+ *
73
+ * Anthropic requires that multiple tool_result responses to a single assistant
74
+ * message with multiple tool_use blocks be grouped into ONE user message
75
+ * containing an array of tool_result blocks. Sending separate consecutive
76
+ * user messages each with a single tool_result block triggers an API error:
77
+ * "messages: Unexpected role change from user to user"
78
+ *
79
+ * See: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#handling-tool-use-and-tool-result-content-blocks
80
+ */
81
+ export function convertMessages(
82
+ messages: AgentMessageShape[],
83
+ ): AnthropicMessage[] {
84
+ const result: AnthropicMessage[] = [];
85
+
86
+ let i = 0;
87
+ while (i < messages.length) {
88
+ const msg = messages[i];
89
+
90
+ switch (msg.role) {
91
+ case "user": {
92
+ const text = extractText(msg.content);
93
+ result.push({ role: "user", content: text });
94
+ i++;
95
+ break;
96
+ }
97
+ case "assistant": {
98
+ const blocks: AnthropicContentBlock[] = [];
99
+ for (const block of msg.content) {
100
+ if (block.type === "text" && block.text !== undefined) {
101
+ blocks.push({ type: "text", text: block.text });
102
+ } else if (block.type === "tool_call" && block.id && block.name) {
103
+ blocks.push({
104
+ type: "tool_use",
105
+ id: block.id,
106
+ name: block.name,
107
+ input: block.arguments ?? {},
108
+ });
109
+ }
110
+ }
111
+ result.push({ role: "assistant", content: blocks });
112
+ i++;
113
+ break;
114
+ }
115
+ case "tool_result": {
116
+ // Gather consecutive tool_result messages into a single user message.
117
+ // Anthropic requires all tool_results for a given assistant turn to be
118
+ // in one user message with an array of tool_result content blocks.
119
+ const toolResults: AnthropicContentBlock[] = [];
120
+ while (i < messages.length && messages[i].role === "tool_result") {
121
+ const tr = messages[i] as Extract<
122
+ AgentMessageShape,
123
+ { role: "tool_result" }
124
+ >;
125
+ const text = extractText(tr.content);
126
+ toolResults.push({
127
+ type: "tool_result",
128
+ tool_use_id: tr.tool_call_id,
129
+ content: text,
130
+ is_error: tr.is_error,
131
+ });
132
+ i++;
133
+ }
134
+ result.push({ role: "user", content: toolResults });
135
+ break;
136
+ }
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ // --- Conversion: Rust tool definitions -> Anthropic tools ---
144
+
145
+ export function convertTools(tools: ToolDefinition[]): AnthropicTool[] {
146
+ return tools.map((t) => ({
147
+ name: t.name,
148
+ description: `${t.label}: ${t.description}`,
149
+ input_schema: t.parameters,
150
+ }));
151
+ }
152
+
153
+ // --- Conversion: Anthropic response -> Rust LlmResult ---
154
+
155
+ export function convertResponse(
156
+ resp: AnthropicResponse,
157
+ providerName: string,
158
+ modelId: string,
159
+ ): { llmResult: object; chunks: object[] } {
160
+ const content: ContentBlock[] = [];
161
+
162
+ for (const block of resp.content) {
163
+ if (block.type === "text") {
164
+ content.push({ type: "text", text: block.text });
165
+ } else if (block.type === "tool_use") {
166
+ content.push({
167
+ type: "tool_call",
168
+ id: block.id,
169
+ name: block.name,
170
+ arguments: block.input,
171
+ });
172
+ }
173
+ }
174
+
175
+ const stopReason = resp.stop_reason === "tool_use" ? "tool_use" : "end_turn";
176
+
177
+ const usage: TokenUsage = {
178
+ input: resp.usage.input_tokens,
179
+ output: resp.usage.output_tokens,
180
+ cache_read: resp.usage.cache_read_input_tokens ?? 0,
181
+ cache_write: resp.usage.cache_creation_input_tokens ?? 0,
182
+ total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
183
+ };
184
+
185
+ const assistantMsg = {
186
+ content,
187
+ api: providerName,
188
+ provider: providerName,
189
+ model: modelId,
190
+ stop_reason: stopReason,
191
+ error_message: null,
192
+ timestamp: Date.now(),
193
+ usage,
194
+ };
195
+
196
+ // Build streaming chunks: a Start chunk + TextDelta chunks for each text block
197
+ const chunks: object[] = [];
198
+ chunks.push({
199
+ kind: "start",
200
+ content: [{ type: "text", text: "" }],
201
+ api: providerName,
202
+ provider: providerName,
203
+ model: modelId,
204
+ stop_reason: stopReason,
205
+ error_message: null,
206
+ timestamp: 0,
207
+ usage: {
208
+ input: 0,
209
+ output: 0,
210
+ cache_read: 0,
211
+ cache_write: 0,
212
+ total_tokens: 0,
213
+ },
214
+ });
215
+
216
+ for (const block of resp.content) {
217
+ if (block.type === "text" && block.text.length > 0) {
218
+ chunks.push({ kind: "text_delta", text: block.text });
219
+ }
220
+ }
221
+
222
+ return {
223
+ llmResult: { Ok: assistantMsg },
224
+ chunks,
225
+ };
226
+ }
227
+
228
+ // --- Main call ---
229
+
230
+ export interface AnthropicConfig {
231
+ apiKey: string;
232
+ baseUrl: string;
233
+ model: string;
234
+ maxTokens?: number;
235
+ }
236
+
237
+ /**
238
+ * Call the Anthropic Messages API and return a ProviderResult.
239
+ */
240
+ export async function callAnthropic(
241
+ request: LlmRequest,
242
+ config: AnthropicConfig,
243
+ ): Promise<ProviderResult> {
244
+ const log: string[] = [];
245
+ const providerName = "anthropic";
246
+
247
+ const body = {
248
+ model: config.model,
249
+ max_tokens: config.maxTokens ?? 4096,
250
+ system: request.system_prompt,
251
+ messages: convertMessages(request.messages),
252
+ tools: convertTools(request.tools),
253
+ };
254
+
255
+ log.push(
256
+ `anthropic_request: model=${config.model}, messages=${body.messages.length}, tools=${body.tools.length}`,
257
+ );
258
+
259
+ let resp: Response;
260
+ try {
261
+ resp = await fetch(`${config.baseUrl}/v1/messages`, {
262
+ method: "POST",
263
+ headers: {
264
+ "Content-Type": "application/json",
265
+ "x-api-key": config.apiKey,
266
+ "anthropic-version": "2023-06-01",
267
+ },
268
+ body: JSON.stringify(body),
269
+ });
270
+ } catch (err) {
271
+ const msg = err instanceof Error ? err.message : String(err);
272
+ log.push(`anthropic_network_error: ${msg}`);
273
+ return {
274
+ llmResult: {
275
+ Err: { error: { code: "network_error", message: msg }, aborted: false },
276
+ },
277
+ chunks: [],
278
+ log,
279
+ };
280
+ }
281
+
282
+ if (!resp.ok) {
283
+ let errorBody: AnthropicError | null = null;
284
+ try {
285
+ errorBody = (await resp.json()) as AnthropicError;
286
+ } catch {
287
+ // ignore parse failure
288
+ }
289
+ const code = `http_${resp.status}`;
290
+ const message =
291
+ errorBody?.error?.message ?? `HTTP ${resp.status}: ${resp.statusText}`;
292
+ log.push(`anthropic_error: ${code} - ${message}`);
293
+ return {
294
+ llmResult: {
295
+ Err: { error: { code, message }, aborted: false },
296
+ },
297
+ chunks: [],
298
+ log,
299
+ };
300
+ }
301
+
302
+ const data = (await resp.json()) as AnthropicResponse;
303
+ log.push(
304
+ `anthropic_response: stop_reason=${data.stop_reason}, content_blocks=${data.content.length}`,
305
+ );
306
+
307
+ const { llmResult, chunks } = convertResponse(
308
+ data,
309
+ providerName,
310
+ config.model,
311
+ );
312
+ return { llmResult, chunks, log };
313
+ }
314
+
315
+ // --- SDK factory ---
316
+
317
+ import type { AgentModel, ModelRequest, ModelResponse } from "../../types.ts";
318
+ import { createAgentError } from "../../errors.ts";
319
+
320
+ export function anthropic(config: {
321
+ apiKey: string;
322
+ model: string;
323
+ baseUrl?: string;
324
+ maxTokens?: number;
325
+ }): AgentModel {
326
+ const anthropicConfig: AnthropicConfig = {
327
+ apiKey: config.apiKey,
328
+ baseUrl: config.baseUrl ?? "https://api.anthropic.com",
329
+ model: config.model,
330
+ maxTokens: config.maxTokens,
331
+ };
332
+
333
+ return {
334
+ id: config.model,
335
+ contextWindow: 200000,
336
+ maxTokens: config.maxTokens ?? 4096,
337
+ capabilities: {
338
+ vision: config.model.includes("vision") || config.model.startsWith("claude-3-5"),
339
+ jsonMode: true,
340
+ functionCalling: true,
341
+ streaming: true,
342
+ },
343
+ async generate(request: ModelRequest): Promise<ModelResponse> {
344
+ const llmRequest = {
345
+ system_prompt: request.instructions,
346
+ messages: request.messages.map((msg): AgentMessageShape => {
347
+ const content = msg.content.map((c): ContentBlock => {
348
+ if (c.type === "text") return { type: "text", text: c.text };
349
+ if (c.type === "tool_call") return { type: "tool_call", id: c.id, name: c.name, arguments: c.arguments as Record<string, unknown> };
350
+ if (c.type === "image") return { type: "image", media_type: c.mimeType, data: c.data };
351
+ return { type: "text", text: "" };
352
+ });
353
+ const timestamp = msg.timestamp ?? Date.now();
354
+ if (msg.role === "user") {
355
+ return { role: "user", content, timestamp };
356
+ }
357
+ if (msg.role === "assistant") {
358
+ return {
359
+ role: "assistant",
360
+ content,
361
+ api: "sdk",
362
+ provider: "sdk",
363
+ model: request.tools[0]?.name ?? "sdk-model",
364
+ stop_reason: "end_turn",
365
+ error_message: null,
366
+ timestamp,
367
+ usage: { input: 0, output: 0, cache_read: 0, cache_write: 0, total_tokens: 0 },
368
+ };
369
+ }
370
+ // tool_result
371
+ return {
372
+ role: "tool_result",
373
+ tool_call_id: msg.tool_call_id ?? "",
374
+ tool_name: msg.content.find((c) => c.type === "text")?.text?.slice(0, 50) ?? "unknown",
375
+ content,
376
+ details: {},
377
+ is_error: false,
378
+ timestamp,
379
+ };
380
+ }),
381
+ tools: request.tools.map((t) => ({
382
+ name: t.name,
383
+ label: t.name,
384
+ description: t.description,
385
+ parameters: t.inputSchema as object,
386
+ execution_mode: "parallel" as const,
387
+ })),
388
+ };
389
+
390
+ try {
391
+ const result = await callAnthropic(llmRequest, anthropicConfig);
392
+
393
+ if ("Err" in result.llmResult) {
394
+ const err = (result.llmResult as { Err: { error: { code: string; message: string } } }).Err.error;
395
+ throw createAgentError(
396
+ err.code === "network_error" ? "model_unavailable" :
397
+ err.code.startsWith("http_401") ? "model_auth_failed" :
398
+ err.code.startsWith("http_429") ? "model_rate_limited" :
399
+ "model_unavailable",
400
+ err.message,
401
+ { recoverable: err.code === "http_429" },
402
+ );
403
+ }
404
+
405
+ const ok = (result.llmResult as { Ok: object }).Ok as {
406
+ content: Array<{ type: string; text?: string; id?: string; name?: string; arguments?: unknown }>;
407
+ stop_reason: string;
408
+ usage?: { input: number; output: number; cache_read: number; cache_write: number; total_tokens: number };
409
+ };
410
+
411
+ return {
412
+ content: ok.content.map((b) => {
413
+ if (b.type === "text") return { type: "text", text: b.text ?? "" };
414
+ if (b.type === "tool_call") return { type: "tool_call", id: b.id ?? "", name: b.name ?? "", arguments: b.arguments ?? {} };
415
+ return { type: "text", text: "" };
416
+ }),
417
+ stopReason: ok.stop_reason === "tool_use" ? "tool_call" : "end",
418
+ usage: ok.usage,
419
+ model: config.model,
420
+ raw: result,
421
+ };
422
+ } catch (e) {
423
+ if (e && typeof e === "object" && "code" in e) throw e;
424
+ throw createAgentError("model_unavailable", e instanceof Error ? e.message : String(e), { cause: e, recoverable: false });
425
+ }
426
+ },
427
+ };
428
+ }
429
+
430
+ // --- Helpers ---
431
+
432
+ function extractText(content: ContentBlock[]): string {
433
+ return content
434
+ .filter(
435
+ (b): b is typeof b & { text: string } =>
436
+ b.type === "text" && b.text !== undefined,
437
+ )
438
+ .map((b) => b.text)
439
+ .join("\n");
440
+ }