@giselles-ai/sandbox-agent 0.1.6 → 0.1.7

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/dist/index.d.ts CHANGED
@@ -1,10 +1,51 @@
1
- type AgentRunnerOptions = {
2
- apiKey?: string;
3
- baseUrl?: string;
1
+ import { z } from 'zod';
2
+ import { Sandbox } from '@vercel/sandbox';
3
+
4
+ type BaseChatRequest = {
5
+ message: string;
6
+ session_id?: string;
7
+ sandbox_id?: string;
8
+ };
9
+ type ChatCommand = {
10
+ cmd: string;
11
+ args: string[];
12
+ env?: Record<string, string>;
13
+ };
14
+ type ChatAgent<TRequest extends BaseChatRequest> = {
15
+ requestSchema: z.ZodType<TRequest>;
16
+ snapshotId?: string;
17
+ prepareSandbox(input: {
18
+ input: TRequest;
19
+ sandbox: Sandbox;
20
+ }): Promise<void>;
21
+ createCommand(input: {
22
+ input: TRequest;
23
+ }): ChatCommand;
4
24
  };
5
- type AgentRunnerHandler = {
6
- POST: (request: Request) => Promise<Response>;
25
+ type RunChatInput<TRequest extends BaseChatRequest> = {
26
+ agent: ChatAgent<TRequest>;
27
+ signal: AbortSignal;
28
+ input: TRequest;
29
+ };
30
+ declare function runChat<TRequest extends BaseChatRequest>(input: RunChatInput<TRequest>): Promise<Response>;
31
+
32
+ declare const geminiRequestSchema: z.ZodObject<{
33
+ message: z.ZodString;
34
+ session_id: z.ZodOptional<z.ZodString>;
35
+ sandbox_id: z.ZodOptional<z.ZodString>;
36
+ relay_session_id: z.ZodOptional<z.ZodString>;
37
+ relay_token: z.ZodOptional<z.ZodString>;
38
+ }, z.core.$strip>;
39
+ type GeminiAgentRequest = z.infer<typeof geminiRequestSchema>;
40
+ type GeminiAgentOptions = {
41
+ snapshotId?: string;
42
+ env?: Record<string, string>;
43
+ tools?: {
44
+ browser?: {
45
+ relayUrl?: string;
46
+ };
47
+ };
7
48
  };
8
- declare function handleAgentRunner(options?: AgentRunnerOptions): AgentRunnerHandler;
49
+ declare function createGeminiAgent(options?: GeminiAgentOptions): ChatAgent<GeminiAgentRequest>;
9
50
 
10
- export { type AgentRunnerHandler, type AgentRunnerOptions, handleAgentRunner };
51
+ export { type BaseChatRequest, type ChatAgent, type ChatCommand, type RunChatInput, createGeminiAgent, runChat };
package/dist/index.js CHANGED
@@ -1,201 +1,241 @@
1
- // src/index.ts
1
+ // src/agents/gemini-agent.ts
2
2
  import { z } from "zod";
3
- var DEFAULT_ENDPOINT = "https://studio.giselles.ai/agent-api";
4
- var DEBUG_ENABLED = process.env.GISELLE_AGENT_DEBUG === "true" || process.env.DEBUG === "1";
5
- function debugLog(context, message) {
6
- if (!DEBUG_ENABLED) {
7
- return;
8
- }
9
- console.info("[agent-runner]", JSON.stringify({ ...context, message }));
10
- }
11
- var agentRunSchema = z.object({
12
- type: z.literal("agent.run"),
3
+ var GEMINI_SETTINGS_PATH = "/home/vercel-sandbox/.gemini/settings.json";
4
+ var geminiRequestSchema = z.object({
13
5
  message: z.string().min(1),
14
- document: z.string().optional(),
15
6
  session_id: z.string().min(1).optional(),
16
- sandbox_id: z.string().min(1).optional()
7
+ sandbox_id: z.string().min(1).optional(),
8
+ relay_session_id: z.string().min(1).optional(),
9
+ relay_token: z.string().min(1).optional()
17
10
  });
18
- function invalidRequestResponse(detail) {
19
- return Response.json(
20
- {
21
- ok: false,
22
- errorCode: "INVALID_REQUEST",
23
- message: "Invalid request payload.",
24
- error: detail
25
- },
26
- { status: 400 }
27
- );
28
- }
29
- function invalidConfigResponse(message) {
30
- return Response.json(
31
- {
32
- ok: false,
33
- errorCode: "INVALID_CONFIG",
34
- message
35
- },
36
- { status: 500 }
37
- );
11
+ function requiredEnv(env, name) {
12
+ const value = env[name]?.trim();
13
+ if (!value) {
14
+ throw new Error(`Missing required environment variable: ${name}`);
15
+ }
16
+ return value;
38
17
  }
39
- function badGatewayResponse(message) {
40
- return Response.json(
18
+ async function patchGeminiSettingsTransportEnv(sandbox, bridgeTransportEnv) {
19
+ const buffer = await sandbox.readFileToBuffer({
20
+ path: GEMINI_SETTINGS_PATH
21
+ });
22
+ if (!buffer) {
23
+ throw new Error(
24
+ `Gemini settings not found in sandbox at ${GEMINI_SETTINGS_PATH}. Ensure the snapshot contains a pre-configured settings.json.`
25
+ );
26
+ }
27
+ const settings = JSON.parse(new TextDecoder().decode(buffer));
28
+ if (settings.mcpServers) {
29
+ settings.mcpServers = Object.fromEntries(
30
+ Object.entries(settings.mcpServers).map(([key, server]) => [
31
+ key,
32
+ {
33
+ ...server,
34
+ env: { ...server.env, ...bridgeTransportEnv }
35
+ }
36
+ ])
37
+ );
38
+ }
39
+ await sandbox.writeFiles([
41
40
  {
42
- ok: false,
43
- errorCode: "UPSTREAM_UNAVAILABLE",
44
- message
45
- },
46
- { status: 502 }
47
- );
48
- }
49
- function resolveApiKey(input) {
50
- return input?.apiKey?.trim() || process.env.GISELLE_SANDBOX_AGENT_API_KEY?.trim() || process.env.GISELLE_API_KEY?.trim() || "";
41
+ path: GEMINI_SETTINGS_PATH,
42
+ content: Buffer.from(JSON.stringify(settings, null, 2))
43
+ }
44
+ ]);
51
45
  }
52
- function parseAbsoluteEndpoint(baseUrl) {
53
- try {
54
- const url = new URL(baseUrl);
55
- if (url.protocol !== "http:" && url.protocol !== "https:") {
56
- return null;
46
+ function createGeminiRequestSchema(browserEnabled) {
47
+ if (!browserEnabled) {
48
+ return geminiRequestSchema;
49
+ }
50
+ return geminiRequestSchema.superRefine((value, ctx) => {
51
+ if (!value.relay_session_id) {
52
+ ctx.addIssue({
53
+ code: "custom",
54
+ path: ["relay_session_id"],
55
+ message: "relay_session_id is required when tools.browser is enabled."
56
+ });
57
+ }
58
+ if (!value.relay_token) {
59
+ ctx.addIssue({
60
+ code: "custom",
61
+ path: ["relay_token"],
62
+ message: "relay_token is required when tools.browser is enabled."
63
+ });
57
64
  }
58
- return url.toString();
59
- } catch {
60
- return null;
65
+ });
66
+ }
67
+ function assertBrowserToolRelayCredentials(parsed) {
68
+ if (!parsed.relay_session_id || !parsed.relay_token) {
69
+ throw new Error("relay_session_id and relay_token are required.");
61
70
  }
62
71
  }
63
- function resolveEndpoint(input) {
64
- const baseUrl = input.baseUrl?.trim();
65
- if (baseUrl) {
66
- const endpoint = parseAbsoluteEndpoint(baseUrl);
67
- if (!endpoint) {
72
+ function createGeminiAgent(options = {}) {
73
+ const env = options.env ?? {};
74
+ const snapshotId = options.snapshotId?.trim() || requiredEnv(env, "SANDBOX_SNAPSHOT_ID");
75
+ const browserToolEnabled = options.tools?.browser !== void 0;
76
+ const browserToolRelayUrl = options.tools?.browser?.relayUrl?.trim();
77
+ if (browserToolEnabled) {
78
+ requiredEnv(env, "BROWSER_TOOL_RELAY_URL");
79
+ requiredEnv(env, "BROWSER_TOOL_RELAY_SESSION_ID");
80
+ requiredEnv(env, "BROWSER_TOOL_RELAY_TOKEN");
81
+ }
82
+ if (browserToolEnabled && !browserToolRelayUrl) {
83
+ throw new Error("tools.browser.relayUrl is empty.");
84
+ }
85
+ return {
86
+ requestSchema: createGeminiRequestSchema(browserToolEnabled),
87
+ snapshotId,
88
+ async prepareSandbox({ input, sandbox }) {
89
+ if (!browserToolEnabled) {
90
+ return;
91
+ }
92
+ requiredEnv(env, "VERCEL_OIDC_TOKEN");
93
+ assertBrowserToolRelayCredentials(input);
94
+ await patchGeminiSettingsTransportEnv(sandbox, env);
95
+ },
96
+ createCommand({ input }) {
97
+ const args = [
98
+ "--prompt",
99
+ input.message,
100
+ "--output-format",
101
+ "stream-json",
102
+ "--approval-mode",
103
+ "yolo"
104
+ ];
105
+ if (input.session_id) {
106
+ args.push("--resume", input.session_id);
107
+ }
68
108
  return {
69
- ok: false,
70
- message: "`baseUrl` must be an absolute HTTP(S) endpoint URL, e.g. https://studio.giselles.ai/agent-api."
109
+ cmd: "gemini",
110
+ args,
111
+ env: {
112
+ GEMINI_API_KEY: requiredEnv(env, "GEMINI_API_KEY")
113
+ }
71
114
  };
72
115
  }
73
- debugLog(
74
- {
75
- baseUrl: input.baseUrl
76
- },
77
- `resolveEndpoint: using baseUrl -> ${endpoint}`
78
- );
79
- return { ok: true, endpoint };
116
+ };
117
+ }
118
+
119
+ // src/chat-run.ts
120
+ import { Writable } from "stream";
121
+ import { Sandbox } from "@vercel/sandbox";
122
+ function emitText(controller, text, encoder) {
123
+ if (text.length === 0) {
124
+ return;
80
125
  }
81
- debugLog(
82
- {
83
- baseUrl: input.baseUrl
84
- },
85
- `resolveEndpoint: using default -> ${DEFAULT_ENDPOINT}`
86
- );
87
- return { ok: true, endpoint: DEFAULT_ENDPOINT };
126
+ controller.enqueue(encoder.encode(text));
88
127
  }
89
- function handleAgentRunner(options) {
90
- const apiKey = resolveApiKey(options);
91
- const optionSnapshot = {
92
- baseUrl: options?.baseUrl?.trim(),
93
- hasApiKey: Boolean(apiKey),
94
- hasInlineApiKey: Boolean(options?.apiKey?.trim()),
95
- hasEnvApiKey: Boolean(process.env.GISELLE_SANDBOX_AGENT_API_KEY?.trim()) || Boolean(process.env.GISELLE_API_KEY?.trim())
96
- };
97
- debugLog(
98
- optionSnapshot,
99
- `handleAgentRunner initialized (${options ? "custom options" : "default options"})`
100
- );
101
- return {
102
- POST: async (request) => {
103
- debugLog(
104
- {
105
- baseUrl: optionSnapshot.baseUrl
106
- },
107
- `POST request start: host=${new URL(request.url).host}`
108
- );
109
- const payload = await request.json().catch(() => null);
110
- const parsed = agentRunSchema.safeParse(payload);
111
- if (!parsed.success) {
112
- debugLog(
113
- {
114
- baseUrl: optionSnapshot.baseUrl
115
- },
116
- `payload invalid: ${parsed.error.issues.length} issues`
117
- );
118
- return invalidRequestResponse(parsed.error.flatten());
119
- }
120
- const endpointResult = resolveEndpoint({
121
- baseUrl: options?.baseUrl
122
- });
123
- if (!endpointResult.ok) {
124
- debugLog(
125
- {
126
- baseUrl: optionSnapshot.baseUrl
127
- },
128
- `invalid endpoint config: ${endpointResult.message}`
129
- );
130
- return invalidConfigResponse(endpointResult.message);
131
- }
132
- debugLog(
133
- {
134
- baseUrl: optionSnapshot.baseUrl
135
- },
136
- `POST forwarding to endpoint: ${endpointResult.endpoint}`
137
- );
138
- const headers = {
139
- "content-type": "application/json"
128
+ function emitEvent(controller, payload, encoder) {
129
+ emitText(controller, `${JSON.stringify(payload)}
130
+ `, encoder);
131
+ }
132
+ function runChat(input) {
133
+ const parsed = input.input;
134
+ const stream = new ReadableStream({
135
+ start(controller) {
136
+ const encoder = new TextEncoder();
137
+ const abortController = new AbortController();
138
+ let closed = false;
139
+ const close = () => {
140
+ if (closed) {
141
+ return;
142
+ }
143
+ closed = true;
144
+ try {
145
+ controller.close();
146
+ } catch {
147
+ }
140
148
  };
141
- if (apiKey) {
142
- headers.authorization = `Bearer ${apiKey}`;
143
- debugLog(
144
- {
145
- baseUrl: optionSnapshot.baseUrl
146
- },
147
- "Authorization header attached"
148
- );
149
- } else {
150
- debugLog(
151
- {
152
- baseUrl: optionSnapshot.baseUrl
153
- },
154
- "Authorization header skipped (apiKey missing)"
155
- );
156
- }
157
- let upstreamResponse;
158
- try {
159
- upstreamResponse = await fetch(endpointResult.endpoint, {
160
- method: "POST",
161
- headers,
162
- body: JSON.stringify(parsed.data),
163
- signal: request.signal
164
- });
165
- } catch (error) {
166
- debugLog(
167
- {
168
- baseUrl: optionSnapshot.baseUrl
169
- },
170
- `upstream fetch failed: ${error instanceof Error ? error.message : String(error)}`
171
- );
172
- const message = error instanceof Error ? error.message : "Failed to connect to upstream API.";
173
- return badGatewayResponse(message);
149
+ const onAbort = () => {
150
+ if (!abortController.signal.aborted) {
151
+ abortController.abort();
152
+ }
153
+ close();
154
+ };
155
+ if (input.signal.aborted) {
156
+ onAbort();
157
+ return;
174
158
  }
175
- debugLog(
176
- {
177
- baseUrl: optionSnapshot.baseUrl
178
- },
179
- `upstream responded: status=${upstreamResponse.status}, content-type=${upstreamResponse.headers.get("content-type") ?? "(none)"}, content-encoding=${upstreamResponse.headers.get("content-encoding") ?? "(none)"}`
180
- );
181
- const responseHeaders = new Headers(upstreamResponse.headers);
182
- responseHeaders.delete("content-encoding");
183
- responseHeaders.delete("Content-Encoding");
184
- responseHeaders.delete("content-length");
185
- responseHeaders.delete("transfer-encoding");
186
- responseHeaders.set(
187
- "Content-Type",
188
- responseHeaders.get("Content-Type") ?? "application/x-ndjson; charset=utf-8"
189
- );
190
- responseHeaders.set("Cache-Control", "no-cache, no-transform");
191
- return new Response(upstreamResponse.body, {
192
- status: upstreamResponse.status,
193
- statusText: upstreamResponse.statusText,
194
- headers: responseHeaders
195
- });
159
+ input.signal.addEventListener("abort", onAbort);
160
+ const enqueueEvent = (payload) => {
161
+ if (closed) {
162
+ return;
163
+ }
164
+ emitEvent(controller, payload, encoder);
165
+ };
166
+ const enqueueStdout = (text) => {
167
+ if (closed) {
168
+ return;
169
+ }
170
+ emitText(controller, text, encoder);
171
+ };
172
+ void (async () => {
173
+ try {
174
+ const sandbox = parsed.sandbox_id ? await Sandbox.get({ sandboxId: parsed.sandbox_id }) : await (async () => {
175
+ const snapshotId = input.agent.snapshotId?.trim();
176
+ if (!snapshotId) {
177
+ throw new Error(
178
+ "Agent must provide snapshotId when sandbox_id is not provided."
179
+ );
180
+ }
181
+ return Sandbox.create({
182
+ source: {
183
+ type: "snapshot",
184
+ snapshotId
185
+ }
186
+ });
187
+ })();
188
+ enqueueEvent({ type: "sandbox", sandbox_id: sandbox.sandboxId });
189
+ await input.agent.prepareSandbox({
190
+ input: parsed,
191
+ sandbox
192
+ });
193
+ const command = input.agent.createCommand({
194
+ input: parsed
195
+ });
196
+ await sandbox.runCommand({
197
+ cmd: command.cmd,
198
+ args: command.args,
199
+ env: command.env ?? {},
200
+ stdout: new Writable({
201
+ write(chunk, _encoding, callback) {
202
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
203
+ enqueueStdout(text);
204
+ callback();
205
+ }
206
+ }),
207
+ stderr: new Writable({
208
+ write(chunk, _encoding, callback) {
209
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
210
+ enqueueEvent({ type: "stderr", content: text });
211
+ callback();
212
+ }
213
+ }),
214
+ signal: abortController.signal
215
+ });
216
+ } catch (error) {
217
+ if (abortController.signal.aborted) {
218
+ return;
219
+ }
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ enqueueEvent({ type: "stderr", content: `[error] ${message}` });
222
+ } finally {
223
+ input.signal.removeEventListener("abort", onAbort);
224
+ close();
225
+ }
226
+ })();
196
227
  }
197
- };
228
+ });
229
+ return Promise.resolve(
230
+ new Response(stream, {
231
+ headers: {
232
+ "Content-Type": "application/x-ndjson; charset=utf-8",
233
+ "Cache-Control": "no-cache, no-transform"
234
+ }
235
+ })
236
+ );
198
237
  }
199
238
  export {
200
- handleAgentRunner
239
+ createGeminiAgent,
240
+ runChat
201
241
  };
@@ -1,44 +1,12 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { ReactNode, Dispatch, SetStateAction } from 'react';
3
- import { BrowserToolStatus, SnapshotField, BrowserToolAction, ExecutionReport } from '@giselles-ai/browser-tool';
4
-
5
- type PromptPanelProps = {
6
- defaultInstruction?: string;
7
- defaultDocument?: string;
8
- mount?: "bottom-right" | "inline";
9
- };
10
- declare function PromptPanel({ defaultInstruction, defaultDocument, mount, }: PromptPanelProps): react_jsx_runtime.JSX.Element;
11
-
12
- type RunInput = {
13
- instruction: string;
14
- document?: string;
15
- };
16
- type BrowserToolPlan = {
17
- fields: SnapshotField[];
18
- actions: BrowserToolAction[];
19
- warnings: string[];
20
- };
21
- type BrowserToolProviderProps = {
22
- endpoint: string;
23
- children: ReactNode;
24
- };
25
- type BrowserToolContextValue = {
26
- endpoint: string;
27
- status: BrowserToolStatus;
28
- lastPlan: BrowserToolPlan | null;
29
- lastExecution: ExecutionReport | null;
30
- error: string | null;
31
- setError: Dispatch<SetStateAction<string | null>>;
32
- run: (input: RunInput) => Promise<BrowserToolPlan>;
33
- apply: (actions: BrowserToolAction[], fields: SnapshotField[]) => ExecutionReport;
34
- };
35
- declare function BrowserToolProvider({ endpoint, children, }: BrowserToolProviderProps): react_jsx_runtime.JSX.Element;
36
-
37
1
  type AgentMessage = {
38
2
  id: string;
39
3
  role: "user" | "assistant";
40
4
  content: string;
41
5
  };
6
+ type StreamEvent = {
7
+ type?: string;
8
+ [key: string]: unknown;
9
+ };
42
10
  type ToolEvent = {
43
11
  id: string;
44
12
  toolId: string;
@@ -49,8 +17,17 @@ type ToolEvent = {
49
17
  };
50
18
  type RelayStatus = "idle" | "connecting" | "connected" | "error";
51
19
  type AgentStatus = RelayStatus | "running";
20
+ type AgentToolContext = {
21
+ sendRelayResponse: (payload: Record<string, unknown>) => Promise<void>;
22
+ setError: (message: string) => void;
23
+ addWarnings: (warnings: string[]) => void;
24
+ };
25
+ type AgentToolHandler = {
26
+ handleRelayRequest: (event: StreamEvent, context: AgentToolContext) => Promise<boolean>;
27
+ };
52
28
  type UseAgentOptions = {
53
29
  endpoint: string;
30
+ tools?: Record<string, AgentToolHandler>;
54
31
  };
55
32
  type AgentHookState = {
56
33
  status: AgentStatus;
@@ -66,8 +43,6 @@ type AgentHookState = {
66
43
  document?: string;
67
44
  }) => Promise<void>;
68
45
  };
69
- declare function useAgent({ endpoint }: UseAgentOptions): AgentHookState;
70
-
71
- declare function useBrowserTool(): BrowserToolContextValue;
46
+ declare function useAgent({ endpoint, tools }: UseAgentOptions): AgentHookState;
72
47
 
73
- export { type AgentHookState, type AgentMessage, type AgentStatus, BrowserToolProvider, PromptPanel, type ToolEvent, type UseAgentOptions, useAgent, useBrowserTool };
48
+ export { type AgentHookState, type AgentMessage, type AgentStatus, type AgentToolContext, type AgentToolHandler, type ToolEvent, type UseAgentOptions, useAgent };
@@ -1,302 +1,7 @@
1
- // src/react/prompt-panel.tsx
2
- import { useMemo as useMemo2, useState as useState2 } from "react";
3
-
4
- // src/react/use-browser-tool.ts
5
- import { useContext } from "react";
6
-
7
- // src/react/provider.tsx
8
- import { execute, snapshot } from "@giselles-ai/browser-tool/dom";
9
- import {
10
- createContext,
11
- useCallback,
12
- useMemo,
13
- useState
14
- } from "react";
15
- import { jsx } from "react/jsx-runtime";
16
- var BrowserToolContext = createContext(
17
- null
18
- );
19
- function isRecord(value) {
20
- return Boolean(value) && typeof value === "object";
21
- }
22
- function isAction(value) {
23
- if (!isRecord(value) || typeof value.action !== "string" || typeof value.fieldId !== "string") {
24
- return false;
25
- }
26
- if (value.action === "click") {
27
- return true;
28
- }
29
- if ((value.action === "fill" || value.action === "select") && typeof value.value === "string") {
30
- return true;
31
- }
32
- return false;
33
- }
34
- function parseWarnings(value) {
35
- if (!Array.isArray(value)) {
36
- return [];
37
- }
38
- return value.filter((item) => typeof item === "string");
39
- }
40
- function BrowserToolProvider({
41
- endpoint,
42
- children
43
- }) {
44
- const [status, setStatus] = useState("idle");
45
- const [lastPlan, setLastPlan] = useState(null);
46
- const [lastExecution, setLastExecution] = useState(
47
- null
48
- );
49
- const [error, setError] = useState(null);
50
- const run = useCallback(
51
- async ({ instruction, document }) => {
52
- const trimmedInstruction = instruction.trim();
53
- if (!trimmedInstruction) {
54
- throw new Error("Instruction is required.");
55
- }
56
- setError(null);
57
- setStatus("snapshotting");
58
- try {
59
- const fields = snapshot();
60
- setStatus("planning");
61
- const response = await fetch(endpoint, {
62
- method: "POST",
63
- headers: {
64
- "content-type": "application/json"
65
- },
66
- body: JSON.stringify({
67
- instruction: trimmedInstruction,
68
- document: document?.trim() ? document.trim() : void 0,
69
- fields
70
- })
71
- });
72
- const payload = await response.json().catch(() => null);
73
- if (!response.ok) {
74
- const message = isRecord(payload) && typeof payload.error === "string" ? payload.error : `Planning failed with status ${response.status}`;
75
- throw new Error(message);
76
- }
77
- const actions = isRecord(payload) && Array.isArray(payload.actions) ? payload.actions.filter(isAction) : [];
78
- const warnings = isRecord(payload) ? parseWarnings(payload.warnings) : [];
79
- const plan = {
80
- fields,
81
- actions,
82
- warnings
83
- };
84
- setLastPlan(plan);
85
- setLastExecution(null);
86
- setStatus("ready");
87
- return plan;
88
- } catch (runError) {
89
- const message = runError instanceof Error ? runError.message : "Failed to run planner.";
90
- setError(message);
91
- setStatus("error");
92
- throw runError;
93
- }
94
- },
95
- [endpoint]
96
- );
97
- const apply = useCallback(
98
- (actions, fields) => {
99
- setError(null);
100
- setStatus("applying");
101
- try {
102
- const report = execute(actions, fields);
103
- setLastExecution(report);
104
- setStatus("idle");
105
- return report;
106
- } catch (applyError) {
107
- const message = applyError instanceof Error ? applyError.message : "Failed to apply actions.";
108
- setError(message);
109
- setStatus("error");
110
- throw applyError;
111
- }
112
- },
113
- []
114
- );
115
- const contextValue = useMemo(
116
- () => ({
117
- endpoint,
118
- status,
119
- lastPlan,
120
- lastExecution,
121
- error,
122
- setError,
123
- run,
124
- apply
125
- }),
126
- [apply, endpoint, error, lastExecution, lastPlan, run, status]
127
- );
128
- return /* @__PURE__ */ jsx(BrowserToolContext.Provider, { value: contextValue, children });
129
- }
130
-
131
- // src/react/use-browser-tool.ts
132
- function useBrowserTool() {
133
- const context = useContext(BrowserToolContext);
134
- if (!context) {
135
- throw new Error(
136
- "useBrowserTool must be used inside <BrowserToolProvider />."
137
- );
138
- }
139
- return context;
140
- }
141
-
142
- // src/react/prompt-panel.tsx
143
- import { jsx as jsx2, jsxs } from "react/jsx-runtime";
144
- function describeAction(action) {
145
- if (action.action === "click") {
146
- return `click ${action.fieldId}`;
147
- }
148
- if (action.action === "fill") {
149
- return `fill ${action.fieldId} = ${JSON.stringify(action.value)}`;
150
- }
151
- return `select ${action.fieldId} = ${JSON.stringify(action.value)}`;
152
- }
153
- function PromptPanel({
154
- defaultInstruction = "",
155
- defaultDocument = "",
156
- mount = "bottom-right"
157
- }) {
158
- const { status, run, apply, lastPlan, lastExecution, error, setError } = useBrowserTool();
159
- const [instruction, setInstruction] = useState2(defaultInstruction);
160
- const [documentText, setDocumentText] = useState2(defaultDocument);
161
- const [notice, setNotice] = useState2(null);
162
- const isPlanning = status === "snapshotting" || status === "planning";
163
- const isApplying = status === "applying";
164
- const combinedWarnings = useMemo2(() => {
165
- const planWarnings = lastPlan?.warnings ?? [];
166
- const executionWarnings = lastExecution?.warnings ?? [];
167
- return [...planWarnings, ...executionWarnings];
168
- }, [lastExecution, lastPlan]);
169
- async function handlePlan() {
170
- if (!instruction.trim()) {
171
- return;
172
- }
173
- setNotice(null);
174
- setError(null);
175
- try {
176
- const plan = await run({
177
- instruction: instruction.trim(),
178
- document: documentText.trim() || void 0
179
- });
180
- if (plan.actions.length === 0) {
181
- setNotice(
182
- "No actions were generated. Try giving more explicit instructions."
183
- );
184
- return;
185
- }
186
- setNotice(
187
- `Plan created: ${plan.actions.length} action(s). Review and click Apply.`
188
- );
189
- } catch (planError) {
190
- setNotice(
191
- planError instanceof Error ? planError.message : "Planning failed."
192
- );
193
- }
194
- }
195
- function handleApply() {
196
- if (!lastPlan || lastPlan.actions.length === 0) {
197
- return;
198
- }
199
- setNotice(null);
200
- setError(null);
201
- const report = apply(lastPlan.actions, lastPlan.fields);
202
- setNotice(
203
- `Applied ${report.applied} action(s), skipped ${report.skipped}.`
204
- );
205
- }
206
- const wrapperClass = mount === "bottom-right" ? "fixed bottom-4 right-4 z-50 w-[min(28rem,calc(100vw-2rem))]" : "w-full max-w-xl";
207
- return /* @__PURE__ */ jsx2("section", { className: wrapperClass, children: /* @__PURE__ */ jsxs("div", { className: "rounded-2xl border border-slate-700 bg-slate-950/95 p-4 shadow-2xl backdrop-blur", children: [
208
- /* @__PURE__ */ jsxs("div", { className: "mb-3 flex items-center justify-between", children: [
209
- /* @__PURE__ */ jsx2("p", { className: "text-xs uppercase tracking-[0.15em] text-cyan-300", children: "Browser Tool Prompt Panel" }),
210
- /* @__PURE__ */ jsxs("p", { className: "text-[11px] text-slate-400", children: [
211
- "status: ",
212
- status
213
- ] })
214
- ] }),
215
- /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
216
- /* @__PURE__ */ jsxs("label", { className: "block", children: [
217
- /* @__PURE__ */ jsx2("span", { className: "mb-1 block text-xs text-slate-300", children: "Instruction" }),
218
- /* @__PURE__ */ jsx2(
219
- "textarea",
220
- {
221
- rows: 2,
222
- value: instruction,
223
- onChange: (event) => setInstruction(event.target.value),
224
- placeholder: "Fill title and body with a concise summary...",
225
- className: "w-full rounded-lg border border-slate-700 bg-slate-900/90 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
226
- }
227
- )
228
- ] }),
229
- /* @__PURE__ */ jsxs("label", { className: "block", children: [
230
- /* @__PURE__ */ jsx2("span", { className: "mb-1 block text-xs text-slate-300", children: "Document (optional)" }),
231
- /* @__PURE__ */ jsx2(
232
- "textarea",
233
- {
234
- rows: 4,
235
- value: documentText,
236
- onChange: (event) => setDocumentText(event.target.value),
237
- placeholder: "Paste source document here",
238
- className: "w-full rounded-lg border border-slate-700 bg-slate-900/90 px-3 py-2 text-sm text-slate-100 outline-none focus:border-cyan-400"
239
- }
240
- )
241
- ] }),
242
- /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
243
- /* @__PURE__ */ jsx2(
244
- "button",
245
- {
246
- type: "button",
247
- onClick: handlePlan,
248
- disabled: !instruction.trim() || isPlanning || isApplying,
249
- className: "rounded-lg bg-cyan-400 px-3 py-2 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-50",
250
- children: isPlanning ? "Planning..." : "Plan"
251
- }
252
- ),
253
- /* @__PURE__ */ jsx2(
254
- "button",
255
- {
256
- type: "button",
257
- onClick: handleApply,
258
- disabled: !lastPlan || lastPlan.actions.length === 0 || isPlanning || isApplying,
259
- className: "rounded-lg border border-slate-600 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
260
- children: isApplying ? "Applying..." : "Apply"
261
- }
262
- )
263
- ] }),
264
- notice ? /* @__PURE__ */ jsx2("p", { className: "text-xs text-slate-300", children: notice }) : null,
265
- error ? /* @__PURE__ */ jsx2("p", { className: "text-xs text-rose-400", children: error }) : null
266
- ] }),
267
- /* @__PURE__ */ jsxs("div", { className: "mt-4 border-t border-slate-800 pt-3", children: [
268
- /* @__PURE__ */ jsx2("p", { className: "text-xs font-medium uppercase tracking-[0.12em] text-slate-400", children: "Action Plan" }),
269
- !lastPlan ? /* @__PURE__ */ jsx2("p", { className: "mt-2 text-xs text-slate-500", children: "No plan yet." }) : lastPlan.actions.length === 0 ? /* @__PURE__ */ jsx2("p", { className: "mt-2 text-xs text-slate-500", children: "Planner returned no actions." }) : /* @__PURE__ */ jsx2("ul", { className: "mt-2 space-y-1", children: lastPlan.actions.map((action, index) => /* @__PURE__ */ jsx2(
270
- "li",
271
- {
272
- className: "rounded-md border border-slate-800 bg-slate-900/80 px-2 py-1 text-xs text-slate-200",
273
- children: describeAction(action)
274
- },
275
- `${action.fieldId}-${action.action}-${index}`
276
- )) }),
277
- combinedWarnings.length > 0 ? /* @__PURE__ */ jsxs("div", { className: "mt-3 rounded-md border border-amber-400/30 bg-amber-400/10 p-2", children: [
278
- /* @__PURE__ */ jsx2("p", { className: "text-[11px] font-semibold uppercase tracking-[0.12em] text-amber-200", children: "Warnings" }),
279
- /* @__PURE__ */ jsx2("ul", { className: "mt-1 space-y-1 text-xs text-amber-100", children: combinedWarnings.map((warning, index) => /* @__PURE__ */ jsxs(
280
- "li",
281
- {
282
- children: [
283
- "- ",
284
- warning
285
- ]
286
- },
287
- `${warning}-${// biome-ignore lint/suspicious/noArrayIndexKey: wip
288
- index}`
289
- )) })
290
- ] }) : null
291
- ] })
292
- ] }) });
293
- }
294
-
295
1
  // src/react/use-agent.ts
296
- import { execute as execute2, snapshot as snapshot2 } from "@giselles-ai/browser-tool/dom";
297
- import { useCallback as useCallback2, useEffect, useMemo as useMemo3, useRef, useState as useState3 } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
298
3
  var LOG_PREFIX = "[agent-relay-client]";
299
- function isRecord2(value) {
4
+ function isRecord(value) {
300
5
  return Boolean(value) && typeof value === "object";
301
6
  }
302
7
  function asString(value) {
@@ -306,7 +11,7 @@ function asNumber(value) {
306
11
  return typeof value === "number" && Number.isFinite(value) ? value : null;
307
12
  }
308
13
  function extractWarnings(value) {
309
- if (!isRecord2(value) || !Array.isArray(value.warnings)) {
14
+ if (!isRecord(value) || !Array.isArray(value.warnings)) {
310
15
  return [];
311
16
  }
312
17
  return value.warnings.filter(
@@ -366,17 +71,24 @@ function dedupeStringArray(next) {
366
71
  const nextSet = new Set(next);
367
72
  return Array.from(nextSet);
368
73
  }
369
- function useAgent({ endpoint }) {
370
- const [relayStatus, setRelayStatus] = useState3("idle");
371
- const [running, setRunning] = useState3(false);
372
- const [messages, setMessages] = useState3([]);
373
- const [tools, setTools] = useState3([]);
374
- const [warnings, setWarnings] = useState3([]);
375
- const [stderrLogs, setStderrLogs] = useState3([]);
376
- const [sandboxId, setSandboxId] = useState3(null);
377
- const [geminiSessionId, setGeminiSessionId] = useState3(null);
378
- const [error, setError] = useState3(null);
379
- const normalizedEndpoint = useMemo3(
74
+ function normalizeTools(tools) {
75
+ if (!tools) {
76
+ return [];
77
+ }
78
+ return Object.values(tools);
79
+ }
80
+ function useAgent({ endpoint, tools }) {
81
+ const [relayStatus, setRelayStatus] = useState("idle");
82
+ const [running, setRunning] = useState(false);
83
+ const [messages, setMessages] = useState([]);
84
+ const [toolEvents, setToolEvents] = useState([]);
85
+ const [warnings, setWarnings] = useState([]);
86
+ const [stderrLogs, setStderrLogs] = useState([]);
87
+ const [sandboxId, setSandboxId] = useState(null);
88
+ const [geminiSessionId, setGeminiSessionId] = useState(null);
89
+ const [error, setError] = useState(null);
90
+ const [toolEventHandlers] = useMemo(() => [normalizeTools(tools)], [tools]);
91
+ const normalizedEndpoint = useMemo(
380
92
  () => endpoint.replace(/\/+$/, ""),
381
93
  [endpoint]
382
94
  );
@@ -386,7 +98,7 @@ function useAgent({ endpoint }) {
386
98
  const sessionRef = useRef(null);
387
99
  const messagesAssistantId = useRef(null);
388
100
  const assistantBufferRef = useRef("");
389
- const cleanupRelay = useCallback2(() => {
101
+ const cleanupRelay = useCallback(() => {
390
102
  if (eventSourceRef.current) {
391
103
  eventSourceRef.current.close();
392
104
  eventSourceRef.current = null;
@@ -396,7 +108,7 @@ function useAgent({ endpoint }) {
396
108
  reconnectTimerRef.current = null;
397
109
  }
398
110
  }, []);
399
- const handleRelayResponse = useCallback2(
111
+ const handleRelayResponse = useCallback(
400
112
  async (payload) => {
401
113
  const currentSession = sessionRef.current;
402
114
  if (!currentSession) {
@@ -434,9 +146,15 @@ function useAgent({ endpoint }) {
434
146
  },
435
147
  [normalizedEndpoint]
436
148
  );
437
- const handleRelayEvent = useCallback2(
149
+ const addWarnings = useCallback((next) => {
150
+ if (next.length === 0) {
151
+ return;
152
+ }
153
+ setWarnings((current) => dedupeStringArray([...current, ...next]));
154
+ }, []);
155
+ const handleRelayEvent = useCallback(
438
156
  async (event) => {
439
- if (!isRecord2(event) || typeof event.type !== "string") {
157
+ if (!isRecord(event) || typeof event.type !== "string") {
440
158
  return;
441
159
  }
442
160
  if (event.type === "ready") {
@@ -446,72 +164,56 @@ function useAgent({ endpoint }) {
446
164
  if (!requestId) {
447
165
  return;
448
166
  }
449
- try {
450
- if (event.type === "error_response") {
451
- const message = asString(event.message);
452
- if (message) {
167
+ if (event.type === "error_response") {
168
+ const message = asString(event.message);
169
+ if (message) {
170
+ setError(message);
171
+ }
172
+ return;
173
+ }
174
+ if (event.type === "snapshot_request" || event.type === "execute_request") {
175
+ const handlerContext = {
176
+ sendRelayResponse: handleRelayResponse,
177
+ setError,
178
+ addWarnings
179
+ };
180
+ for (const handler of toolEventHandlers) {
181
+ try {
182
+ const handled = await handler.handleRelayRequest(
183
+ event,
184
+ handlerContext
185
+ );
186
+ if (handled) {
187
+ return;
188
+ }
189
+ } catch (error2) {
190
+ const message = error2 instanceof Error ? error2.message : "Relay execution failed.";
453
191
  setError(message);
192
+ setRelayStatus("error");
193
+ await handleRelayResponse({
194
+ type: "error_response",
195
+ requestId,
196
+ message
197
+ });
198
+ return;
454
199
  }
455
- return;
456
- }
457
- if (event.type === "snapshot_request") {
458
- console.info(`${LOG_PREFIX} sse.snapshot_request`, {
459
- requestId
460
- });
461
- const fields = snapshot2();
462
- console.info(`${LOG_PREFIX} sse.snapshot_result`, {
463
- requestId,
464
- fieldCount: fields.length
465
- });
466
- await handleRelayResponse({
467
- type: "snapshot_response",
468
- requestId,
469
- fields
470
- });
471
- return;
472
- }
473
- if (event.type === "execute_request") {
474
- console.info(`${LOG_PREFIX} sse.execute_request`, {
475
- requestId,
476
- actionCount: Array.isArray(event.actions) ? event.actions.length : 0
477
- });
478
- const actions = Array.isArray(event.actions) ? event.actions : [];
479
- const fields = Array.isArray(event.fields) ? event.fields : [];
480
- const report = execute2(actions, fields);
481
- console.info(`${LOG_PREFIX} sse.execute_result`, {
482
- requestId,
483
- applied: report.applied,
484
- skipped: report.skipped
485
- });
486
- setWarnings(
487
- (current) => dedupeStringArray([...current, ...report.warnings])
488
- );
489
- await handleRelayResponse({
490
- type: "execute_response",
491
- requestId,
492
- report
493
- });
494
- return;
495
200
  }
496
201
  await handleRelayResponse({
497
202
  type: "error_response",
498
203
  requestId,
499
204
  message: `Unsupported relay request type: ${event.type}`
500
205
  });
501
- } catch (relayError) {
502
- const message = relayError instanceof Error ? relayError.message : "Relay execution failed.";
503
- setError(message);
504
- setRelayStatus("error");
505
- await handleRelayResponse({
506
- type: "error_response",
507
- requestId,
508
- message
509
- });
206
+ return;
510
207
  }
208
+ await handleRelayResponse({
209
+ type: "error_response",
210
+ requestId,
211
+ message: `Unsupported relay request type: ${event.type}`
212
+ });
511
213
  },
512
- [handleRelayResponse]
214
+ [addWarnings, handleRelayResponse, toolEventHandlers]
513
215
  );
514
- const connect = useCallback2(() => {
216
+ const connect = useCallback(() => {
515
217
  const currentSession = sessionRef.current;
516
218
  if (!currentSession) {
517
219
  return;
@@ -578,7 +280,7 @@ function useAgent({ endpoint }) {
578
280
  cleanupRelay();
579
281
  };
580
282
  }, [cleanupRelay]);
581
- const handleStreamEvent = useCallback2(
283
+ const handleStreamEvent = useCallback(
582
284
  (event) => {
583
285
  if (typeof event.type !== "string") {
584
286
  return;
@@ -678,7 +380,7 @@ function useAgent({ endpoint }) {
678
380
  if (event.type === "tool_use") {
679
381
  const toolId = asString(event.tool_id) ?? crypto.randomUUID();
680
382
  const toolName = asString(event.tool_name) ?? "tool";
681
- setTools((current) => [
383
+ setToolEvents((current) => [
682
384
  ...current,
683
385
  {
684
386
  id: crypto.randomUUID(),
@@ -696,7 +398,7 @@ function useAgent({ endpoint }) {
696
398
  }
697
399
  const status2 = asString(event.status);
698
400
  const nextStatus = status2 === "success" || status2 === "error" ? status2 : void 0;
699
- setTools(
401
+ setToolEvents(
700
402
  (current) => current.map(
701
403
  (tool) => tool.toolId === toolId ? {
702
404
  ...tool,
@@ -715,7 +417,7 @@ function useAgent({ endpoint }) {
715
417
  },
716
418
  [connect]
717
419
  );
718
- const sendMessage = useCallback2(
420
+ const sendMessage = useCallback(
719
421
  async ({ message, document }) => {
720
422
  const trimmedMessage = message.trim();
721
423
  if (!trimmedMessage || running) {
@@ -804,7 +506,7 @@ function useAgent({ endpoint }) {
804
506
  return {
805
507
  status,
806
508
  messages,
807
- tools,
509
+ tools: toolEvents,
808
510
  warnings,
809
511
  stderrLogs,
810
512
  sandboxId,
@@ -814,8 +516,5 @@ function useAgent({ endpoint }) {
814
516
  };
815
517
  }
816
518
  export {
817
- BrowserToolProvider,
818
- PromptPanel,
819
- useAgent,
820
- useBrowserTool
519
+ useAgent
821
520
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giselles-ai/sandbox-agent",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "Apache-2.0",
@@ -28,10 +28,11 @@
28
28
  "build": "pnpm clean && tsup --config tsup.ts",
29
29
  "clean": "rm -rf dist *.tsbuildinfo",
30
30
  "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "test": "pnpm exec vitest run",
31
32
  "format": "pnpm exec biome check --write ."
32
33
  },
33
34
  "dependencies": {
34
- "@giselles-ai/browser-tool": "0.1.6",
35
+ "@vercel/sandbox": "^1.0.0",
35
36
  "zod": "4.3.6"
36
37
  },
37
38
  "peerDependencies": {
@@ -39,6 +40,7 @@
39
40
  "react-dom": ">=19.0.0"
40
41
  },
41
42
  "devDependencies": {
43
+ "@google/gemini-cli-core": "^0.28.2",
42
44
  "@types/node": "25.0.10",
43
45
  "@types/react": "19.2.10",
44
46
  "@types/react-dom": "19.2.3",