@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,174 @@
1
+ // ToolRegistryBuilder — converts SDK AgentTools[] into WASM ToolMap and LLM ToolDefinition[].
2
+ // Uses zod-to-json-schema for schema conversion.
3
+ // Detects duplicate tool names and throws AgentError.
4
+ // Preserves details field in ToolResult.
5
+
6
+ import type { ToolCall, ToolDefinition, ToolResult } from "../../../pi_host_web.js";
7
+ import type { AgentTools, AgentToolDefinition } from "../../types.ts";
8
+ import { createAgentError } from "../../errors.ts";
9
+ import { createArtifactToolRegistry } from "./service.ts";
10
+ import type { ArtifactStore } from "../engine.ts";
11
+ import { z } from "zod";
12
+ import { zodToJsonSchema } from "zod-to-json-schema";
13
+
14
+ export type ToolMap = Record<string, (call: ToolCall) => Promise<ToolResult> | ToolResult>;
15
+
16
+ export class ToolRegistryBuilder {
17
+ /**
18
+ * Build a WASM ToolMap from AgentTools packs.
19
+ * Artifact tools are wired with the store at build time.
20
+ */
21
+ build(
22
+ tools: AgentTools[],
23
+ artifactStore?: ArtifactStore,
24
+ sessionId?: string,
25
+ ): ToolMap {
26
+ const toolMap: ToolMap = {};
27
+ const seenNames = new Set<string>();
28
+
29
+ for (const pack of tools) {
30
+ for (const def of pack.definitions) {
31
+ if (seenNames.has(def.name)) {
32
+ throw createAgentError(
33
+ "tool_duplicate",
34
+ `Duplicate tool name: ${def.name}`,
35
+ { recoverable: false },
36
+ );
37
+ }
38
+ seenNames.add(def.name);
39
+
40
+ const handler = pack.getHandler(def.name);
41
+ if (handler) {
42
+ toolMap[def.name] = async (call: ToolCall) => {
43
+ try {
44
+ let parsedInput: unknown;
45
+ if (def.inputSchema && isZodSchema(def.inputSchema)) {
46
+ const schema = def.inputSchema as z.ZodTypeAny;
47
+ const parseResult = schema.safeParse(call.arguments);
48
+ if (!parseResult.success) {
49
+ return {
50
+ content: [{ type: "text", text: `Invalid input: ${parseResult.error.message}` }],
51
+ error: true,
52
+ };
53
+ }
54
+ parsedInput = parseResult.data;
55
+ } else {
56
+ parsedInput = call.arguments;
57
+ }
58
+
59
+ const output = await handler(parsedInput);
60
+
61
+ // If output is already a ToolResult, preserve it (including details)
62
+ if (isToolResult(output)) {
63
+ return output;
64
+ }
65
+
66
+ // Otherwise wrap the output
67
+ const text = typeof output === "string" ? output : JSON.stringify(output, null, 2);
68
+ const result: ToolResult = {
69
+ content: [{ type: "text", text }],
70
+ };
71
+
72
+ // Preserve details if the definition provides a details function
73
+ if (def.details) {
74
+ result.details = def.details(output);
75
+ }
76
+
77
+ return result;
78
+ } catch (err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ return {
81
+ content: [{ type: "text", text: message }],
82
+ details: { error: true },
83
+ };
84
+ }
85
+ };
86
+ }
87
+ }
88
+ }
89
+
90
+ // Wire artifact tools with store if any artifact pack was provided
91
+ const hasArtifactTools = tools.some((p) =>
92
+ p.definitions.some((d) => d.name === "artifact_read" || d.name === "artifact_search"),
93
+ );
94
+ if (hasArtifactTools) {
95
+ const artifactRegistry = createArtifactToolRegistry(
96
+ () => 0,
97
+ artifactStore,
98
+ () => sessionId,
99
+ );
100
+ for (const [name, handler] of Object.entries(artifactRegistry)) {
101
+ if (seenNames.has(name)) {
102
+ throw createAgentError(
103
+ "tool_duplicate",
104
+ `Duplicate tool name: ${name}`,
105
+ { recoverable: false },
106
+ );
107
+ }
108
+ toolMap[name] = handler;
109
+ }
110
+ }
111
+
112
+ return toolMap;
113
+ }
114
+
115
+ /**
116
+ * Convert AgentToolDefinition[] to WASM ToolDefinition[] for the LLM.
117
+ * Uses zod-to-json-schema for Zod schemas; passes plain objects through.
118
+ */
119
+ getLlmTools(tools: AgentTools[]): ToolDefinition[] {
120
+ const llmTools: ToolDefinition[] = [];
121
+ const seenNames = new Set<string>();
122
+
123
+ for (const pack of tools) {
124
+ for (const def of pack.definitions) {
125
+ if (seenNames.has(def.name)) {
126
+ throw createAgentError(
127
+ "tool_duplicate",
128
+ `Duplicate tool name: ${def.name}`,
129
+ { recoverable: false },
130
+ );
131
+ }
132
+ seenNames.add(def.name);
133
+
134
+ let parameters: object;
135
+ if (isZodSchema(def.inputSchema)) {
136
+ parameters = zodToJsonSchema(def.inputSchema as z.ZodTypeAny, { name: def.name }) as object;
137
+ } else if (typeof def.inputSchema === "object" && def.inputSchema !== null) {
138
+ parameters = def.inputSchema as object;
139
+ } else {
140
+ parameters = { type: "object", properties: {} };
141
+ }
142
+
143
+ llmTools.push({
144
+ name: def.name,
145
+ label: def.name,
146
+ description: def.description,
147
+ parameters,
148
+ execution_mode: "parallel",
149
+ });
150
+ }
151
+ }
152
+
153
+ return llmTools;
154
+ }
155
+ }
156
+
157
+ function isToolResult(value: unknown): value is ToolResult {
158
+ return (
159
+ typeof value === "object" &&
160
+ value !== null &&
161
+ "content" in value &&
162
+ Array.isArray((value as ToolResult).content)
163
+ );
164
+ }
165
+
166
+ function isZodSchema(value: unknown): value is z.ZodTypeAny {
167
+ return (
168
+ typeof value === "object" &&
169
+ value !== null &&
170
+ "_def" in value &&
171
+ typeof (value as { _def: unknown })._def === "object" &&
172
+ !!(value as { _def: { typeName?: string } })._def?.typeName?.startsWith("Zod")
173
+ );
174
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Tool service — creates a tool registry for the agent run config.
3
+ *
4
+ * Pure JS, no React. Wraps browser tool execution into the SDK ToolMap shape.
5
+ */
6
+
7
+ import type { ToolCall, ToolDefinition, ToolResult } from "../../../pi_host_web.js";
8
+ import type { BrowserRuntime } from "./browserRuntime.ts";
9
+ import type { ArtifactStore } from "../engine.ts";
10
+ import {
11
+ BROWSER_TOOLS,
12
+ executeBrowserTool,
13
+ wrapToolHandler,
14
+ } from "./browser.ts";
15
+
16
+ // ========================================================================
17
+ // Artifact tool schemas
18
+ // ========================================================================
19
+
20
+ const artifactReadSchema: object = {
21
+ type: "object",
22
+ properties: {
23
+ artifact_id: {
24
+ type: "string",
25
+ description: "The artifact id to retrieve (e.g. tool-result-abc123).",
26
+ },
27
+ },
28
+ required: ["artifact_id"],
29
+ additionalProperties: false,
30
+ };
31
+
32
+ const MAX_SEARCH_RESULTS = 50;
33
+
34
+ const artifactSearchSchema: object = {
35
+ type: "object",
36
+ properties: {
37
+ pattern: {
38
+ type: "string",
39
+ description: "Text pattern to search for inside stored artifacts.",
40
+ },
41
+ },
42
+ required: ["pattern"],
43
+ additionalProperties: false,
44
+ };
45
+
46
+ // ========================================================================
47
+ // Artifact tool definitions
48
+ // ========================================================================
49
+
50
+ const ARTIFACT_READ: ToolDefinition = {
51
+ name: "artifact_read",
52
+ label: "Read Artifact",
53
+ description:
54
+ "Read the full original text of a previously stored artifact by its id. " +
55
+ "Use this when a projected tool result references an artifact you need to inspect.",
56
+ parameters: artifactReadSchema,
57
+ execution_mode: "parallel",
58
+ };
59
+
60
+ const ARTIFACT_SEARCH: ToolDefinition = {
61
+ name: "artifact_search",
62
+ label: "Search Artifacts",
63
+ description:
64
+ "Search all stored artifacts for a text pattern. Returns up to 50 matching artifact ids, a short snippet around the first match, and the match count. Use artifact_read to retrieve the full text.",
65
+ parameters: artifactSearchSchema,
66
+ execution_mode: "parallel",
67
+ };
68
+
69
+ /** All artifact tools exposed by the host. */
70
+ export const ARTIFACT_TOOLS: ToolDefinition[] = [
71
+ ARTIFACT_READ,
72
+ ARTIFACT_SEARCH,
73
+ ];
74
+
75
+ // ========================================================================
76
+ // Tool registry
77
+ // ========================================================================
78
+
79
+ export type ToolMap = Record<string, (call: ToolCall) => Promise<ToolResult> | ToolResult>;
80
+
81
+ export function createToolRegistry(runtime: BrowserRuntime): ToolMap {
82
+ return Object.fromEntries(
83
+ BROWSER_TOOLS.map((t) => [
84
+ t.name,
85
+ wrapToolHandler((call: ToolCall) => executeBrowserTool(call, runtime)),
86
+ ]),
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Create artifact tool handlers that read from the agent store.
92
+ * No host handles required — operates on AgentStore directly.
93
+ */
94
+ export function createArtifactToolRegistry(
95
+ _getHandle: () => number,
96
+ artifactStore?: ArtifactStore,
97
+ getSessionId?: () => string | undefined,
98
+ ): ToolMap {
99
+ return {
100
+ artifact_read: wrapToolHandler(async (call: ToolCall) => {
101
+ const artifactId = call.arguments.artifact_id as string;
102
+ if (typeof artifactId !== "string" || artifactId.length === 0) {
103
+ throw new Error("artifact_id must be a non-empty string");
104
+ }
105
+ let text: string;
106
+ if (artifactStore && getSessionId) {
107
+ const sessionId = getSessionId();
108
+ if (sessionId) {
109
+ const stored = await artifactStore.load(sessionId, artifactId);
110
+ text = stored ?? "";
111
+ } else {
112
+ text = "";
113
+ }
114
+ } else {
115
+ text = "";
116
+ }
117
+ if (text === "") {
118
+ throw new Error(`artifact not found: ${artifactId}`);
119
+ }
120
+ return {
121
+ content: [{ type: "text", text }],
122
+ };
123
+ }),
124
+ artifact_search: wrapToolHandler(async (call: ToolCall) => {
125
+ const pattern = call.arguments.pattern as string;
126
+ if (typeof pattern !== "string" || pattern.length === 0) {
127
+ throw new Error("pattern must be a non-empty string");
128
+ }
129
+ let results: Array<{ id: string; snippet: string; match_count: number }>;
130
+ if (artifactStore && getSessionId) {
131
+ const sessionId = getSessionId();
132
+ if (sessionId) {
133
+ results = await artifactStore.search(sessionId, pattern);
134
+ } else {
135
+ results = [];
136
+ }
137
+ } else {
138
+ results = [];
139
+ }
140
+ const capped = results.slice(0, MAX_SEARCH_RESULTS);
141
+ const text = JSON.stringify(
142
+ capped.map((r) => ({
143
+ id: r.id,
144
+ snippet: r.snippet,
145
+ match_count: r.match_count,
146
+ })),
147
+ null,
148
+ 2,
149
+ );
150
+ return {
151
+ content: [{ type: "text", text }],
152
+ };
153
+ }),
154
+ };
155
+ }
156
+
157
+ export { BROWSER_TOOLS };
package/sdk/model.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Model exports — AgentModel interface and defineModel() factory.
2
+ // Provider-neutral contract. Provider factories live in internal/providers/.
3
+
4
+ import type { AgentModel, ModelRequest, ModelResponse } from "./types.ts";
5
+
6
+ export type { AgentModel } from "./types.ts";
7
+
8
+ /**
9
+ * Create a custom AgentModel from a user-provided generate function.
10
+ * Useful for wrapping arbitrary LLM providers or mocking in tests.
11
+ */
12
+ export function defineModel(
13
+ config: {
14
+ id?: string;
15
+ contextWindow?: number;
16
+ maxTokens?: number;
17
+ capabilities?: AgentModel["capabilities"];
18
+ generate: AgentModel["generate"];
19
+ summarize?: AgentModel["summarize"];
20
+ },
21
+ ): AgentModel {
22
+ return {
23
+ id: config.id ?? "custom-model",
24
+ contextWindow: config.contextWindow ?? 100000,
25
+ maxTokens: config.maxTokens ?? 4096,
26
+ capabilities: {
27
+ vision: config.capabilities?.vision ?? false,
28
+ jsonMode: config.capabilities?.jsonMode ?? true,
29
+ functionCalling: config.capabilities?.functionCalling ?? true,
30
+ streaming: config.capabilities?.streaming ?? true,
31
+ },
32
+ generate: config.generate,
33
+ summarize: config.summarize,
34
+ };
35
+ }
@@ -0,0 +1 @@
1
+ export { useAgent } from "./useAgent.ts";
@@ -0,0 +1,334 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { Agent } from "../agent.ts";
3
+ import type {
4
+ AgentConfig,
5
+ AgentInput,
6
+ AgentRunOptions,
7
+ AgentRunResult,
8
+ AgentMessage,
9
+ AgentToolRun,
10
+ AgentArtifactRef,
11
+ AgentStatus,
12
+ AgentError,
13
+ UseAgentResult,
14
+ } from "../types.ts";
15
+ import { createAgentError } from "../errors.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Shallow comparison helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function shallowEqual(a: unknown, b: unknown): boolean {
22
+ if (a === b) return true;
23
+ if (typeof a !== "object" || typeof b !== "object") return false;
24
+ if (a === null || b === null) return false;
25
+
26
+ const aRecord = a as Record<string, unknown>;
27
+ const bRecord = b as Record<string, unknown>;
28
+ const aKeys = Object.keys(aRecord);
29
+ const bKeys = Object.keys(bRecord);
30
+ if (aKeys.length !== bKeys.length) return false;
31
+
32
+ for (const key of aKeys) {
33
+ if (aRecord[key] !== bRecord[key]) return false;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ function shallowArrayEqual(a: unknown[], b: unknown[]): boolean {
39
+ if (a === b) return true;
40
+ if (a.length !== b.length) return false;
41
+ for (let i = 0; i < a.length; i++) {
42
+ if (a[i] !== b[i]) return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function toolsEqual(
48
+ a: AgentConfig["tools"],
49
+ b: AgentConfig["tools"],
50
+ ): boolean {
51
+ if (a === b) return true;
52
+ if (Array.isArray(a) && Array.isArray(b)) {
53
+ return shallowArrayEqual(a, b);
54
+ }
55
+ if (!Array.isArray(a) && !Array.isArray(b)) {
56
+ return shallowEqual(a, b);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // useStableConfig
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Stabilises an `AgentConfig` reference so that the hook only sees a new
67
+ * object when one of the config fields actually changes (by value).
68
+ * Object fields (`context`, `artifacts`, `telemetry`) are compared shallowly.
69
+ * `tools` is compared shallowly (array or single object).
70
+ */
71
+ function useStableConfig(config: AgentConfig): AgentConfig {
72
+ const ref = useRef<AgentConfig>(config);
73
+
74
+ const prev = ref.current;
75
+ const changed =
76
+ prev.sessionId !== config.sessionId ||
77
+ prev.model !== config.model ||
78
+ prev.instructions !== config.instructions ||
79
+ prev.store !== config.store ||
80
+ !shallowEqual(prev.context, config.context) ||
81
+ !shallowEqual(prev.artifacts, config.artifacts) ||
82
+ !shallowEqual(prev.telemetry, config.telemetry) ||
83
+ !toolsEqual(prev.tools, config.tools);
84
+
85
+ if (changed) {
86
+ ref.current = config;
87
+ }
88
+
89
+ return ref.current;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // useAgent
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export function useAgent(config: AgentConfig): UseAgentResult {
97
+ const stableConfig = useStableConfig(config);
98
+ const agentRef = useRef<Agent | null>(null);
99
+
100
+ const [messages, setMessages] = useState<AgentMessage[]>([]);
101
+ const [toolCalls, setToolCalls] = useState<AgentToolRun[]>([]);
102
+ const [artifacts, setArtifacts] = useState<AgentArtifactRef[]>([]);
103
+ const [status, setStatus] = useState<AgentStatus>({ state: "idle" });
104
+ const [error, setError] = useState<AgentError | null>(null);
105
+
106
+ // Create / re-create Agent whenever the stable config changes.
107
+ useEffect(() => {
108
+ const agent = new Agent(stableConfig);
109
+ agentRef.current = agent;
110
+
111
+ const unsubscribers: (() => void)[] = [];
112
+
113
+ unsubscribers.push(
114
+ agent.on("messageStart", (message) => {
115
+ setMessages((prev) => [...prev, message]);
116
+ }),
117
+ );
118
+
119
+ unsubscribers.push(
120
+ agent.on("text", (delta) => {
121
+ setMessages((prev) => {
122
+ const lastIndex = prev.length - 1;
123
+ const last = prev[lastIndex];
124
+ if (!last || last.role !== "assistant") return prev;
125
+
126
+ const content = [...last.content];
127
+ const textIndex = content.findIndex((b) => b.type === "text");
128
+ if (textIndex >= 0) {
129
+ const block = content[textIndex] as { type: "text"; text: string };
130
+ content[textIndex] = { ...block, text: block.text + delta };
131
+ } else {
132
+ content.push({ type: "text", text: delta });
133
+ }
134
+
135
+ return [...prev.slice(0, lastIndex), { ...last, content }];
136
+ });
137
+ }),
138
+ );
139
+
140
+ unsubscribers.push(
141
+ agent.on("messageEnd", (message) => {
142
+ setMessages((prev) => {
143
+ const lastIndex = prev.length - 1;
144
+ if (lastIndex >= 0 && prev[lastIndex].role === "assistant") {
145
+ return [...prev.slice(0, lastIndex), message];
146
+ }
147
+ return [...prev, message];
148
+ });
149
+ }),
150
+ );
151
+
152
+ unsubscribers.push(
153
+ agent.on("toolStart", (tool) => {
154
+ setToolCalls((prev) => [...prev, tool]);
155
+ }),
156
+ );
157
+
158
+ unsubscribers.push(
159
+ agent.on("toolUpdate", (tool) => {
160
+ setToolCalls((prev) => prev.map((t) => (t.id === tool.id ? tool : t)));
161
+ }),
162
+ );
163
+
164
+ unsubscribers.push(
165
+ agent.on("toolEnd", (tool) => {
166
+ setToolCalls((prev) => prev.map((t) => (t.id === tool.id ? tool : t)));
167
+ }),
168
+ );
169
+
170
+ unsubscribers.push(
171
+ agent.on("artifact", (artifact) => {
172
+ setArtifacts((prev) => [...prev, artifact]);
173
+ }),
174
+ );
175
+
176
+ unsubscribers.push(
177
+ agent.on("status", (newStatus) => {
178
+ setStatus(newStatus);
179
+ }),
180
+ );
181
+
182
+ unsubscribers.push(
183
+ agent.on("error", (err) => {
184
+ setError(err);
185
+ }),
186
+ );
187
+
188
+ unsubscribers.push(
189
+ agent.on("done", (result) => {
190
+ if (result.status === "completed") {
191
+ setStatus({ state: "completed" });
192
+ } else if (result.status === "aborted") {
193
+ setStatus({ state: "aborted" });
194
+ } else if (result.status === "failed") {
195
+ setStatus({ state: "failed", message: result.error?.message });
196
+ }
197
+ }),
198
+ );
199
+
200
+ return () => {
201
+ for (const unsub of unsubscribers) {
202
+ unsub();
203
+ }
204
+ agent.dispose();
205
+ agentRef.current = null;
206
+ };
207
+ }, [stableConfig]);
208
+
209
+ const send = useCallback(
210
+ async (
211
+ input: string | AgentInput,
212
+ options?: AgentRunOptions,
213
+ ): Promise<AgentRunResult> => {
214
+ const agent = agentRef.current;
215
+ if (!agent) {
216
+ const err = createAgentError(
217
+ "agent_disposed",
218
+ "Agent is not available",
219
+ { recoverable: false },
220
+ );
221
+ setError(err);
222
+ return {
223
+ status: "failed",
224
+ text: "",
225
+ toolCalls: [],
226
+ artifacts: [],
227
+ error: err,
228
+ };
229
+ }
230
+
231
+ // Prepend user message to messages state before calling run()
232
+ const userMessage: AgentMessage = {
233
+ id: `user-${Date.now()}`,
234
+ role: "user",
235
+ content: [
236
+ { type: "text", text: typeof input === "string" ? input : input.text },
237
+ ],
238
+ timestamp: Date.now(),
239
+ };
240
+ setMessages((prev) => [userMessage, ...prev]);
241
+
242
+ try {
243
+ return await agent.run(input, options);
244
+ } catch (e) {
245
+ const err = createAgentError(
246
+ "internal_error",
247
+ e instanceof Error ? e.message : String(e),
248
+ { cause: e, recoverable: false },
249
+ );
250
+ setError(err);
251
+ return {
252
+ status: "failed",
253
+ text: "",
254
+ toolCalls: [],
255
+ artifacts: [],
256
+ error: err,
257
+ };
258
+ }
259
+ },
260
+ [],
261
+ );
262
+
263
+ const stop = useCallback((reason?: string) => {
264
+ agentRef.current?.stop(reason);
265
+ }, []);
266
+
267
+ const steer = useCallback(
268
+ async (input: string | AgentInput): Promise<void> => {
269
+ const agent = agentRef.current;
270
+ if (!agent) {
271
+ const err = createAgentError(
272
+ "agent_disposed",
273
+ "Agent is not available",
274
+ { recoverable: false },
275
+ );
276
+ setError(err);
277
+ throw err;
278
+ }
279
+ try {
280
+ await agent.steer(input);
281
+ } catch (e) {
282
+ const err = createAgentError(
283
+ "internal_error",
284
+ e instanceof Error ? e.message : String(e),
285
+ { cause: e, recoverable: false },
286
+ );
287
+ setError(err);
288
+ throw err;
289
+ }
290
+ },
291
+ [],
292
+ );
293
+
294
+ const reset = useCallback(async (): Promise<void> => {
295
+ const agent = agentRef.current;
296
+ if (!agent) {
297
+ const err = createAgentError(
298
+ "agent_disposed",
299
+ "Agent is not available",
300
+ { recoverable: false },
301
+ );
302
+ setError(err);
303
+ throw err;
304
+ }
305
+ try {
306
+ setError(null); // Clear error BEFORE await
307
+ await agent.reset();
308
+ setMessages([]);
309
+ setToolCalls([]);
310
+ setArtifacts([]);
311
+ } catch (e) {
312
+ const err = createAgentError(
313
+ "internal_error",
314
+ e instanceof Error ? e.message : String(e),
315
+ { cause: e, recoverable: false },
316
+ );
317
+ setError(err);
318
+ // Error is NOT cleared after failure — it reflects the failure
319
+ throw err;
320
+ }
321
+ }, []);
322
+
323
+ return {
324
+ send,
325
+ stop,
326
+ steer,
327
+ reset,
328
+ status,
329
+ messages,
330
+ toolCalls,
331
+ artifacts,
332
+ error,
333
+ };
334
+ }