@candor-ui/react 1.3.0 → 1.7.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/dist/adapters/ag-ui.d.ts +81 -16
- package/dist/adapters/ag-ui.js +308 -1
- package/dist/adapters/ai-sdk.d.ts +11 -6
- package/dist/adapters/ai-sdk.js +18 -1
- package/dist/adapters/claude-agent-sdk.d.ts +123 -14
- package/dist/adapters/claude-agent-sdk.js +163 -5
- package/dist/adapters/claude-agent-server.d.ts +199 -14
- package/dist/adapters/claude-agent-server.js +448 -49
- package/dist/adapters/use-ag-ui.d.ts +12 -6
- package/dist/adapters/use-ag-ui.js +23 -5
- package/dist/adapters/use-candor-chat.d.ts +1 -1
- package/dist/adapters/use-candor-chat.js +3 -3
- package/dist/adapters/use-claude-agent.d.ts +38 -9
- package/dist/adapters/use-claude-agent.js +96 -20
- package/dist/blocks/copy-field.d.ts +1 -1
- package/dist/blocks/copy-field.js +1 -1
- package/dist/blocks/degraded-panel.d.ts +5 -5
- package/dist/blocks/locale.d.ts +6 -6
- package/dist/blocks/locked.d.ts +8 -8
- package/dist/blocks/relative-time.d.ts +6 -6
- package/dist/blocks/status-dot.d.ts +3 -3
- package/dist/blocks/step-list.d.ts +3 -3
- package/dist/blocks/streaming-text.d.ts +3 -3
- package/dist/chat/agent-avatar.d.ts +4 -4
- package/dist/chat/ai-disclosure.d.ts +37 -0
- package/dist/chat/ai-disclosure.js +48 -0
- package/dist/chat/attachment-preview.d.ts +6 -6
- package/dist/chat/attachment-preview.js +2 -2
- package/dist/chat/card-registry.js +1 -1
- package/dist/chat/citation.d.ts +3 -3
- package/dist/chat/citation.js +6 -4
- package/dist/chat/composer.d.ts +31 -20
- package/dist/chat/composer.js +10 -6
- package/dist/chat/context-meter.d.ts +1 -1
- package/dist/chat/context-meter.js +1 -1
- package/dist/chat/entity-chip.d.ts +3 -3
- package/dist/chat/entity-chip.js +1 -1
- package/dist/chat/folded-turns.d.ts +11 -6
- package/dist/chat/folded-turns.js +2 -2
- package/dist/chat/message-actions.d.ts +6 -6
- package/dist/chat/message-actions.js +1 -1
- package/dist/chat/message-content.d.ts +13 -7
- package/dist/chat/message-content.js +10 -5
- package/dist/chat/message.d.ts +64 -37
- package/dist/chat/message.js +17 -11
- package/dist/chat/payload.d.ts +4 -4
- package/dist/chat/payload.js +5 -2
- package/dist/chat/permission-prompt.d.ts +129 -0
- package/dist/chat/permission-prompt.js +144 -0
- package/dist/chat/prompt-suggestions.d.ts +4 -4
- package/dist/chat/prompt-suggestions.js +1 -1
- package/dist/chat/reasoning.d.ts +3 -3
- package/dist/chat/reasoning.js +1 -1
- package/dist/chat/thread-divider.d.ts +3 -3
- package/dist/chat/thread-event.d.ts +10 -5
- package/dist/chat/thread-event.js +3 -2
- package/dist/chat/thread.d.ts +23 -7
- package/dist/chat/thread.js +29 -6
- package/dist/chat/tool-call.d.ts +7 -2
- package/dist/chat/tool-call.js +8 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/chat.d.ts +53 -38
- package/dist/lib/know.d.ts +14 -12
- package/dist/lib/render-prop.d.ts +4 -4
- package/dist/lib/safe-markdown.d.ts +1 -1
- package/dist/lib/status.d.ts +21 -0
- package/dist/lib/status.js +17 -0
- package/dist/lib/use-shortcut.d.ts +1 -1
- package/dist/lib/use-virtual-list.d.ts +6 -6
- package/dist/styles.css +4 -1
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/button.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,248 @@
|
|
|
1
|
-
|
|
1
|
+
import { claudePermissionResult, claudeQuestions, } from "./claude-agent-sdk.js";
|
|
2
|
+
/**
|
|
3
|
+
* The default ApprovalStore: this process's memory. Answered and closed requests are forgotten after `keepMs`
|
|
4
|
+
* (10 minutes), so it doesn't grow without bound.
|
|
5
|
+
*/
|
|
6
|
+
export function memoryApprovalStore({ keepMs = 10 * 60_000 } = {}) {
|
|
7
|
+
const items = new Map();
|
|
8
|
+
const waiters = new Map();
|
|
9
|
+
const wake = (id) => {
|
|
10
|
+
for (const w of waiters.get(id) ?? [])
|
|
11
|
+
w();
|
|
12
|
+
waiters.delete(id);
|
|
13
|
+
};
|
|
14
|
+
const forget = (id) => setTimeout(() => items.delete(id), keepMs).unref?.();
|
|
15
|
+
const now = () => new Date().toISOString();
|
|
16
|
+
const answer = (id, a) => {
|
|
17
|
+
const item = items.get(id);
|
|
18
|
+
if (!item || item.answer || item.closed)
|
|
19
|
+
return false;
|
|
20
|
+
item.answer = { ...structuredClone(a), at: now() };
|
|
21
|
+
wake(id);
|
|
22
|
+
forget(id);
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
const store = {
|
|
26
|
+
async put(approval) {
|
|
27
|
+
items.set(approval.id, structuredClone(approval));
|
|
28
|
+
},
|
|
29
|
+
async get(id) {
|
|
30
|
+
const a = items.get(id);
|
|
31
|
+
return a && structuredClone(a);
|
|
32
|
+
},
|
|
33
|
+
async answer(id, a) {
|
|
34
|
+
return answer(id, a);
|
|
35
|
+
},
|
|
36
|
+
async close(id, reason) {
|
|
37
|
+
const a = items.get(id);
|
|
38
|
+
if (!a || a.answer || a.closed)
|
|
39
|
+
return false;
|
|
40
|
+
a.closed = { at: now(), reason };
|
|
41
|
+
wake(id);
|
|
42
|
+
forget(id);
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
wait(id, signal) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const settle = () => {
|
|
48
|
+
signal.removeEventListener("abort", onAbort);
|
|
49
|
+
waiters.get(id)?.delete(settle);
|
|
50
|
+
const a = items.get(id);
|
|
51
|
+
resolve(a?.answer ? stripAt(a.answer) : undefined);
|
|
52
|
+
};
|
|
53
|
+
// Stopped before an answer: closed in the same step, so a late answer is refused rather than recorded.
|
|
54
|
+
const onAbort = () => {
|
|
55
|
+
const a = items.get(id);
|
|
56
|
+
if (a && !a.answer && !a.closed) {
|
|
57
|
+
a.closed = { at: now(), reason: "The run was stopped." };
|
|
58
|
+
forget(id);
|
|
59
|
+
}
|
|
60
|
+
settle();
|
|
61
|
+
};
|
|
62
|
+
const a = items.get(id);
|
|
63
|
+
if (!a || a.answer || a.closed)
|
|
64
|
+
return settle();
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
return onAbort();
|
|
67
|
+
let set = waiters.get(id);
|
|
68
|
+
if (!set)
|
|
69
|
+
waiters.set(id, (set = new Set()));
|
|
70
|
+
set.add(settle);
|
|
71
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
async pending(runId) {
|
|
75
|
+
return [...items.values()].filter((a) => !a.answer && !a.closed && (runId === undefined || a.runId === runId)).map((a) => structuredClone(a));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
synchronous.set(store, { get: (id) => items.get(id), answer });
|
|
79
|
+
return store;
|
|
80
|
+
}
|
|
81
|
+
/** The memory store's own synchronous calls, so the 1.4 answer function can stay synchronous. */
|
|
82
|
+
const synchronous = new WeakMap();
|
|
83
|
+
const stripAt = (stored) => {
|
|
84
|
+
const answer = { ...stored };
|
|
85
|
+
delete answer.at;
|
|
86
|
+
return answer;
|
|
87
|
+
};
|
|
88
|
+
/** A `wait` for stores that can't push: reads the request every `intervalMs` (1s) until it's answered or closed. */
|
|
89
|
+
export async function waitByPolling(get, id, signal, intervalMs = 1000) {
|
|
90
|
+
while (!signal.aborted) {
|
|
91
|
+
const a = await get(id);
|
|
92
|
+
if (!a || a.closed)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (a.answer)
|
|
95
|
+
return stripAt(a.answer);
|
|
96
|
+
await new Promise((resolve) => {
|
|
97
|
+
const timer = setTimeout(done, intervalMs);
|
|
98
|
+
function done() {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
signal.removeEventListener("abort", done);
|
|
101
|
+
resolve();
|
|
102
|
+
}
|
|
103
|
+
signal.addEventListener("abort", done, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/** The store streamClaudeAgent and answerClaudeAgentApproval use when you don't pass one. */
|
|
109
|
+
const defaultApprovals = memoryApprovalStore();
|
|
110
|
+
/**
|
|
111
|
+
* A canUseTool that keeps each request in an ApprovalStore and waits for its answer from wherever it's given: your
|
|
112
|
+
* route, a queue, a workflow signal (`approvals.answer(id, { approved: true })`). Use it on its own when your agent
|
|
113
|
+
* runs somewhere other than streamClaudeAgent. When the SDK stops waiting, the request is closed, so a late answer
|
|
114
|
+
* is refused rather than lost.
|
|
115
|
+
*/
|
|
116
|
+
export function claudeCanUseTool({ approvals = defaultApprovals, emit, owner, runId, mode, track, editable, approvalTimeoutMs }) {
|
|
117
|
+
return async (tool, input, request) => {
|
|
118
|
+
const id = crypto.randomUUID();
|
|
119
|
+
const questions = tool === "AskUserQuestion" ? claudeQuestions(input) : undefined;
|
|
120
|
+
const approval = {
|
|
121
|
+
id,
|
|
122
|
+
toolUseId: request.toolUseID,
|
|
123
|
+
tool,
|
|
124
|
+
input,
|
|
125
|
+
title: request.title,
|
|
126
|
+
description: request.description,
|
|
127
|
+
reason: request.decisionReason,
|
|
128
|
+
blockedPath: request.blockedPath,
|
|
129
|
+
defaultToNo: request.defaultToNo,
|
|
130
|
+
displayName: request.displayName,
|
|
131
|
+
// A question has no rule to remember; and a request that mustn't offer one gets none.
|
|
132
|
+
suggestions: questions || request.suppressAlwaysAllowRule ? undefined : request.suggestions,
|
|
133
|
+
noAlwaysAllow: request.suppressAlwaysAllowRule || undefined,
|
|
134
|
+
mode: mode?.(),
|
|
135
|
+
questions,
|
|
136
|
+
agentId: request.agentID,
|
|
137
|
+
mcpServer: request.mcpServer,
|
|
138
|
+
// A question's answers aren't an edit; otherwise the server's policy decides, and the browser can't widen it.
|
|
139
|
+
editable: questions ? false : editable ? editable(tool, input) : true,
|
|
140
|
+
};
|
|
141
|
+
await approvals.put({ id, runId, owner, request: approval, createdAt: new Date().toISOString() });
|
|
142
|
+
track?.add(id);
|
|
143
|
+
emit({ type: "candor_approval", ...approval });
|
|
144
|
+
const timer = approvalTimeoutMs ? setTimeout(() => void approvals.close(id, EXPIRED), approvalTimeoutMs) : undefined;
|
|
145
|
+
const answer = await approvals.wait(id, request.signal);
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
track?.delete(id);
|
|
148
|
+
if (!answer || request.signal.aborted) {
|
|
149
|
+
const stored = await approvals.get(id);
|
|
150
|
+
const reason = request.signal.aborted
|
|
151
|
+
? stored?.answer
|
|
152
|
+
? "The run stopped before the answer could be used."
|
|
153
|
+
: "The run was stopped."
|
|
154
|
+
: (stored?.closed?.reason ?? "The run ended.");
|
|
155
|
+
await approvals.close(id, reason);
|
|
156
|
+
emit({ type: "candor_approval_closed", id, approved: false, reason, expired: reason === EXPIRED || undefined });
|
|
157
|
+
return { behavior: "deny", message: reason, interrupt: request.signal.aborted };
|
|
158
|
+
}
|
|
159
|
+
const edited = answer.approved && answer.updatedInput !== undefined;
|
|
160
|
+
emit({
|
|
161
|
+
type: "candor_approval_closed",
|
|
162
|
+
id,
|
|
163
|
+
approved: answer.approved,
|
|
164
|
+
reason: answer.reason,
|
|
165
|
+
edited: edited || undefined,
|
|
166
|
+
// What actually ran, so the tool card and a replay show it rather than what the agent proposed.
|
|
167
|
+
input: edited ? answer.updatedInput : undefined,
|
|
168
|
+
kept: answer.alwaysAllow?.length || undefined,
|
|
169
|
+
});
|
|
170
|
+
return claudePermissionResult(approval, answer);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const EXPIRED = "Nobody answered in time.";
|
|
174
|
+
/**
|
|
175
|
+
* The default ClaudeRunStore: this process's memory. It keeps at most `maxRuns` finished runs (100), forgetting the
|
|
176
|
+
* oldest finished one first; a run still going is never forgotten. Each run keeps at most `maxEvents` events
|
|
177
|
+
* (5,000): past that, partial-text events are no longer kept (the finished message still is), so a re-attached
|
|
178
|
+
* browser sees text arrive a message at a time instead of as it streams.
|
|
179
|
+
*/
|
|
180
|
+
export function memoryRunStore({ maxRuns = 100, maxEvents = 5000 } = {}) {
|
|
181
|
+
const runs = new Map();
|
|
182
|
+
const notify = (id) => runs.get(id)?.listeners.forEach((l) => l());
|
|
183
|
+
const forgetFinished = () => {
|
|
184
|
+
// Oldest first: Map keeps insertion order. Runs still going are skipped, so their owner is never lost.
|
|
185
|
+
for (const [id, r] of runs) {
|
|
186
|
+
if (runs.size <= maxRuns)
|
|
187
|
+
return;
|
|
188
|
+
if (r.ended)
|
|
189
|
+
runs.delete(id);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const start = (id, owner) => {
|
|
193
|
+
const r = runs.get(id);
|
|
194
|
+
if (r) {
|
|
195
|
+
if (owner !== undefined && r.owner === undefined)
|
|
196
|
+
r.owner = owner;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
runs.set(id, { events: [], ended: false, owner, listeners: new Set() });
|
|
200
|
+
forgetFinished();
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
async start(id, owner) {
|
|
204
|
+
start(id, owner);
|
|
205
|
+
},
|
|
206
|
+
async append(id, event) {
|
|
207
|
+
const r = runs.get(id);
|
|
208
|
+
if (!r || r.ended)
|
|
209
|
+
return;
|
|
210
|
+
if (r.events.length >= maxEvents && event.type === "stream_event")
|
|
211
|
+
return;
|
|
212
|
+
r.events.push(event);
|
|
213
|
+
notify(id);
|
|
214
|
+
},
|
|
215
|
+
async end(id) {
|
|
216
|
+
const r = runs.get(id);
|
|
217
|
+
if (!r)
|
|
218
|
+
return;
|
|
219
|
+
r.ended = true;
|
|
220
|
+
notify(id);
|
|
221
|
+
forgetFinished();
|
|
222
|
+
},
|
|
223
|
+
async read(id, from) {
|
|
224
|
+
const r = runs.get(id);
|
|
225
|
+
return r && { events: r.events.slice(Math.max(0, from)), ended: r.ended };
|
|
226
|
+
},
|
|
227
|
+
subscribe(id, listener) {
|
|
228
|
+
const r = runs.get(id);
|
|
229
|
+
if (!r)
|
|
230
|
+
return () => { };
|
|
231
|
+
r.listeners.add(listener);
|
|
232
|
+
return () => r.listeners.delete(listener);
|
|
233
|
+
},
|
|
234
|
+
async ownerOf(id) {
|
|
235
|
+
return runs.get(id)?.owner;
|
|
236
|
+
},
|
|
237
|
+
async setOwner(id, owner) {
|
|
238
|
+
start(id, owner);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/* ---------------------------------------------------------------- streaming a run */
|
|
243
|
+
/** Runs started by this process, so stop and the mode switch can reach them. */
|
|
244
|
+
const live = new Map();
|
|
245
|
+
const permits = (policy, mode) => (!policy ? true : typeof policy === "function" ? policy(mode) : policy.includes(mode));
|
|
2
246
|
/**
|
|
3
247
|
* Run an agent and stream it to useClaudeAgent.
|
|
4
248
|
*
|
|
@@ -12,52 +256,61 @@ const waiting = new Map();
|
|
|
12
256
|
* )
|
|
13
257
|
* }
|
|
14
258
|
*/
|
|
15
|
-
export function streamClaudeAgent(run, { signal, owner, describeError, onMessage } = {}) {
|
|
259
|
+
export function streamClaudeAgent(run, { signal, owner, describeError, onMessage, onEvent, approvals = defaultApprovals, runs, runId = crypto.randomUUID(), detach = false, prompt, editable, approvalTimeoutMs, modes } = {}) {
|
|
16
260
|
const encoder = new TextEncoder();
|
|
17
261
|
const abortController = new AbortController();
|
|
18
262
|
const mine = new Set();
|
|
263
|
+
let mode;
|
|
19
264
|
let send = () => { };
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
send(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
toolUseId: request.toolUseID,
|
|
38
|
-
tool,
|
|
39
|
-
input,
|
|
40
|
-
title: request.title,
|
|
41
|
-
description: request.description,
|
|
42
|
-
reason: request.decisionReason,
|
|
43
|
-
blockedPath: request.blockedPath,
|
|
44
|
-
defaultToNo: request.defaultToNo,
|
|
45
|
-
});
|
|
46
|
-
});
|
|
265
|
+
// Keeps the record in the order the browser saw it, even when the stores are slow.
|
|
266
|
+
let recorded = Promise.resolve();
|
|
267
|
+
const record = (event) => {
|
|
268
|
+
recorded = recorded.then(async () => {
|
|
269
|
+
await runs?.append(runId, event);
|
|
270
|
+
await onEvent?.(event);
|
|
271
|
+
}).catch((error) => console.error("Recording a Claude agent event failed:", error));
|
|
272
|
+
};
|
|
273
|
+
const emit = (event) => {
|
|
274
|
+
if (event.type === "candor_mode")
|
|
275
|
+
mode = event.mode;
|
|
276
|
+
else if (event.type === "system" && (event.subtype === "init" || event.subtype === "status") && event.permissionMode)
|
|
277
|
+
mode = event.permissionMode;
|
|
278
|
+
record(event);
|
|
279
|
+
send(event);
|
|
280
|
+
};
|
|
281
|
+
const canUseTool = claudeCanUseTool({ approvals, emit, owner, runId, mode: () => mode, track: mine, editable, approvalTimeoutMs });
|
|
47
282
|
const body = new ReadableStream({
|
|
48
283
|
async start(controller) {
|
|
49
284
|
let open = true;
|
|
50
285
|
send = (event) => {
|
|
51
|
-
if (open)
|
|
286
|
+
if (!open)
|
|
287
|
+
return;
|
|
288
|
+
try {
|
|
52
289
|
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// The browser went away mid-run and the run carries on (detach): later events go only to the run store.
|
|
293
|
+
open = false;
|
|
294
|
+
}
|
|
53
295
|
};
|
|
54
296
|
const stop = () => abortController.abort();
|
|
55
|
-
|
|
297
|
+
if (!detach)
|
|
298
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
299
|
+
if (runs?.start)
|
|
300
|
+
await runs.start(runId, owner);
|
|
301
|
+
else if (owner !== undefined)
|
|
302
|
+
await runs?.setOwner?.(runId, owner);
|
|
303
|
+
if (prompt)
|
|
304
|
+
record({ type: "candor_user", ...prompt });
|
|
305
|
+
const entry = { owner, abort: abortController, emit, query: undefined, modes };
|
|
306
|
+
live.set(runId, entry);
|
|
56
307
|
try {
|
|
57
|
-
|
|
308
|
+
const query = run({ canUseTool, abortController, emit, runId });
|
|
309
|
+
entry.query = query;
|
|
310
|
+
for await (const message of query) {
|
|
58
311
|
// Awaited, so bookkeeping such as recording spend finishes before the next message is handled.
|
|
59
312
|
await onMessage?.(message);
|
|
60
|
-
|
|
313
|
+
emit(message);
|
|
61
314
|
// Stopped (the browser went away, or onMessage or a tool aborted the run): nothing more is sent.
|
|
62
315
|
if (abortController.signal.aborted)
|
|
63
316
|
break;
|
|
@@ -66,39 +319,185 @@ export function streamClaudeAgent(run, { signal, owner, describeError, onMessage
|
|
|
66
319
|
catch (error) {
|
|
67
320
|
if (!abortController.signal.aborted) {
|
|
68
321
|
console.error("Claude agent run failed:", error);
|
|
69
|
-
|
|
322
|
+
emit({ type: "candor_error", message: describeError?.(error) ?? "The agent stopped because of an error on the server." });
|
|
70
323
|
}
|
|
71
324
|
}
|
|
72
325
|
finally {
|
|
326
|
+
live.delete(runId);
|
|
73
327
|
// Nobody can answer once the run is over.
|
|
74
|
-
|
|
75
|
-
|
|
328
|
+
await Promise.all([...mine].map((id) => approvals.close(id, "The run ended.")));
|
|
329
|
+
await recorded;
|
|
330
|
+
await runs?.end(runId);
|
|
76
331
|
open = false;
|
|
77
332
|
signal?.removeEventListener("abort", stop);
|
|
78
|
-
|
|
333
|
+
try {
|
|
334
|
+
controller.close();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* already closed by the browser going away */
|
|
338
|
+
}
|
|
79
339
|
}
|
|
80
340
|
},
|
|
81
341
|
cancel() {
|
|
82
|
-
|
|
342
|
+
// The browser stopped reading. With detach, the run goes on and the browser can re-attach.
|
|
343
|
+
if (!detach)
|
|
344
|
+
abortController.abort();
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
return new Response(body, {
|
|
348
|
+
headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no", "x-candor-run-id": runId },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Stream a run's events again, from index `from`, then live until it ends: for a browser re-attaching after a
|
|
353
|
+
* reload. 404 when the store doesn't know the run, 403 when `owner` isn't who started it.
|
|
354
|
+
*
|
|
355
|
+
* // app/api/agent/[runId]/route.ts
|
|
356
|
+
* export async function GET(request: Request, { params }) {
|
|
357
|
+
* const from = Number(new URL(request.url).searchParams.get("from") ?? 0)
|
|
358
|
+
* return attachClaudeAgentRun(runs, (await params).runId, { from, owner: userId, signal: request.signal })
|
|
359
|
+
* }
|
|
360
|
+
*/
|
|
361
|
+
export async function attachClaudeAgentRun(runs, runId, { from = 0, owner, signal, pollMs = 500 } = {}) {
|
|
362
|
+
const first = await runs.read(runId, 0);
|
|
363
|
+
if (!first)
|
|
364
|
+
return Response.json({ error: "No such run." }, { status: 404 });
|
|
365
|
+
const startedBy = await runs.ownerOf?.(runId);
|
|
366
|
+
if (startedBy !== undefined && startedBy !== owner)
|
|
367
|
+
return Response.json({ error: "Not your run." }, { status: 403 });
|
|
368
|
+
const encoder = new TextEncoder();
|
|
369
|
+
let next = Number.isSafeInteger(from) && from > 0 ? from : 0;
|
|
370
|
+
const body = new ReadableStream({
|
|
371
|
+
async start(controller) {
|
|
372
|
+
let wake = () => { };
|
|
373
|
+
// Set by every notification, so one that lands while a read is under way isn't lost to the wait after it.
|
|
374
|
+
let dirty = false;
|
|
375
|
+
const unsubscribe = runs.subscribe?.(runId, () => {
|
|
376
|
+
dirty = true;
|
|
377
|
+
wake();
|
|
378
|
+
});
|
|
379
|
+
try {
|
|
380
|
+
while (!signal?.aborted) {
|
|
381
|
+
dirty = false;
|
|
382
|
+
const page = await runs.read(runId, next);
|
|
383
|
+
if (!page)
|
|
384
|
+
break;
|
|
385
|
+
for (const event of page.events)
|
|
386
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
|
387
|
+
next += page.events.length;
|
|
388
|
+
if (page.ended)
|
|
389
|
+
break;
|
|
390
|
+
if (dirty)
|
|
391
|
+
continue;
|
|
392
|
+
await new Promise((resolve) => {
|
|
393
|
+
const timer = setTimeout(done, unsubscribe ? 30_000 : pollMs);
|
|
394
|
+
function done() {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
signal?.removeEventListener("abort", done);
|
|
397
|
+
resolve();
|
|
398
|
+
}
|
|
399
|
+
wake = done;
|
|
400
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
/* the browser went away */
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
unsubscribe?.();
|
|
409
|
+
try {
|
|
410
|
+
controller.close();
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* already closed */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
83
416
|
},
|
|
84
417
|
});
|
|
85
|
-
return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
|
|
418
|
+
return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no", "x-candor-run-id": runId } });
|
|
419
|
+
}
|
|
420
|
+
/** Stop a run this process is running. False when it isn't running here, or `owner` didn't start it. */
|
|
421
|
+
export function stopClaudeAgentRun(runId, owner) {
|
|
422
|
+
const run = live.get(runId);
|
|
423
|
+
if (!run || (run.owner !== undefined && run.owner !== owner))
|
|
424
|
+
return false;
|
|
425
|
+
run.abort.abort();
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Change a running agent's permission mode (the SDK's `setPermissionMode`), and tell the browser. Only for a query
|
|
430
|
+
* in streaming input mode, as the SDK requires. False when the run isn't here, `owner` didn't start it, the mode
|
|
431
|
+
* isn't allowed, or the SDK refused. The route is where policy is enforced: a mode PermissionMode shows as locked
|
|
432
|
+
* must be left out of `allowed` (or the run's `modes`) too, since anyone can post to the route directly.
|
|
433
|
+
*/
|
|
434
|
+
export async function setClaudeAgentPermissionMode(runId, mode, owner, { allowed } = {}) {
|
|
435
|
+
const run = live.get(runId);
|
|
436
|
+
if (!run?.query || (run.owner !== undefined && run.owner !== owner))
|
|
437
|
+
return false;
|
|
438
|
+
if (!permits(run.modes, mode) || !permits(allowed, mode))
|
|
439
|
+
return false;
|
|
440
|
+
try {
|
|
441
|
+
await run.query.setPermissionMode(mode);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
run.emit({ type: "candor_mode", mode });
|
|
447
|
+
return true;
|
|
86
448
|
}
|
|
449
|
+
/* ---------------------------------------------------------------- answering */
|
|
450
|
+
const isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
87
451
|
/**
|
|
88
|
-
*
|
|
89
|
-
* request
|
|
90
|
-
*
|
|
452
|
+
* Check an answer from the browser against the request it answers, and record it. False when there's no such
|
|
453
|
+
* request, it's already settled, `owner` isn't the run's owner, or the answer doesn't fit the request (an edited
|
|
454
|
+
* input that isn't an object, answers to a request that isn't a question, a suggestion that wasn't offered).
|
|
91
455
|
*
|
|
92
456
|
* // app/api/agent/approve/route.ts
|
|
93
457
|
* export async function POST(request: Request) {
|
|
94
|
-
* const { id,
|
|
95
|
-
* return Response.json({ ok:
|
|
458
|
+
* const { id, ...answer } = await request.json()
|
|
459
|
+
* return Response.json({ ok: await answerClaudeApproval(approvals, id, answer, { owner: userId }) })
|
|
96
460
|
* }
|
|
97
461
|
*/
|
|
462
|
+
export async function answerClaudeApproval(approvals, id, answer, { owner } = {}) {
|
|
463
|
+
const clean = checkAnswer(await approvals.get(id), answer, owner);
|
|
464
|
+
return clean ? approvals.answer(id, clean) : false;
|
|
465
|
+
}
|
|
466
|
+
/** The answer as it will be recorded, or null when it doesn't fit the request or the viewer may not give it. */
|
|
467
|
+
function checkAnswer(stored, answer, owner) {
|
|
468
|
+
if (!stored || stored.answer || stored.closed)
|
|
469
|
+
return null;
|
|
470
|
+
if (stored.owner !== undefined && stored.owner !== owner)
|
|
471
|
+
return null;
|
|
472
|
+
if (typeof answer?.approved !== "boolean")
|
|
473
|
+
return null;
|
|
474
|
+
const r = stored.request;
|
|
475
|
+
if (answer.updatedInput !== undefined && (!isPlainObject(answer.updatedInput) || r.questions || r.editable === false))
|
|
476
|
+
return null;
|
|
477
|
+
if (answer.answers !== undefined && (!r.questions || !Array.isArray(answer.answers)))
|
|
478
|
+
return null;
|
|
479
|
+
if (answer.alwaysAllow !== undefined) {
|
|
480
|
+
const offered = r.noAlwaysAllow ? 0 : (r.suggestions?.length ?? 0);
|
|
481
|
+
if (!Array.isArray(answer.alwaysAllow) || answer.alwaysAllow.some((i) => !Number.isInteger(i) || i < 0 || i >= offered))
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (answer.reason != null && typeof answer.reason !== "string")
|
|
485
|
+
return null;
|
|
486
|
+
return {
|
|
487
|
+
approved: answer.approved,
|
|
488
|
+
...(answer.reason && { reason: answer.reason.slice(0, 2000) }),
|
|
489
|
+
...(answer.approved && answer.updatedInput && { updatedInput: answer.updatedInput }),
|
|
490
|
+
...(answer.approved && answer.answers && { answers: answer.answers }),
|
|
491
|
+
...(answer.approved && answer.alwaysAllow?.length && { alwaysAllow: answer.alwaysAllow }),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Answer a waiting permission request in the default, in-memory store. Kept from 1.4; answerClaudeApproval takes a
|
|
496
|
+
* store and the full answer (an edited input, answers to questions, "always allow"). Returns false when there's no
|
|
497
|
+
* such request, or `owner` doesn't match the run's owner.
|
|
498
|
+
*/
|
|
98
499
|
export function answerClaudeAgentApproval(id, approved, reason, owner) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
request.answer(approved, reason);
|
|
103
|
-
return true;
|
|
500
|
+
const memory = synchronous.get(defaultApprovals);
|
|
501
|
+
const clean = checkAnswer(memory.get(id), { approved, reason }, owner);
|
|
502
|
+
return clean ? memory.answer(id, clean) : false;
|
|
104
503
|
}
|
|
@@ -5,16 +5,16 @@ import { type FromAgUiOptions } from "./ag-ui.js";
|
|
|
5
5
|
export type UseAgUiOptions = Omit<FromAgUiOptions, "running" | "now"> & {
|
|
6
6
|
/** Your AG-UI endpoint (LangGraph, Mastra, CrewAI, Google ADK, Pydantic AI…). It receives a RunAgentInput and streams events back. */
|
|
7
7
|
url: string;
|
|
8
|
-
headers?: Record<string, string
|
|
8
|
+
headers?: Record<string, string> | undefined;
|
|
9
9
|
/** Replaces the global fetch, e.g. to add auth or to play a script in tests and demos. */
|
|
10
|
-
fetch?: typeof fetch;
|
|
10
|
+
fetch?: typeof fetch | undefined;
|
|
11
11
|
/** Continue an existing thread instead of starting a new one. */
|
|
12
|
-
threadId?: string;
|
|
12
|
+
threadId?: string | undefined;
|
|
13
13
|
/** Sent with every run as `forwardedProps`, next to the `references` the person mentioned. */
|
|
14
|
-
forwardedProps?: Record<string, unknown
|
|
14
|
+
forwardedProps?: Record<string, unknown> | undefined;
|
|
15
15
|
/** Tools and context for every run, as AG-UI defines them. Candor doesn't run frontend tools for you. */
|
|
16
|
-
tools?: RunAgentParameters["tools"];
|
|
17
|
-
context?: RunAgentParameters["context"];
|
|
16
|
+
tools?: RunAgentParameters["tools"] | undefined;
|
|
17
|
+
context?: RunAgentParameters["context"] | undefined;
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
20
|
* Wires an AG-UI agent to Candor: messages for <Thread>/<Message>, props for
|
|
@@ -41,6 +41,12 @@ export declare function useAgUi(options: UseAgUiOptions): {
|
|
|
41
41
|
/** The request failed; say so, and let the person send again. */
|
|
42
42
|
error: string | undefined;
|
|
43
43
|
threadId: string | undefined;
|
|
44
|
+
/** The agent's shared state (STATE_SNAPSHOT/STATE_DELTA); undefined until it sends one. */
|
|
45
|
+
state: unknown;
|
|
46
|
+
/** Set, with the reason, when a state update couldn't be applied and `state` may be behind. */
|
|
47
|
+
stateOutOfDate: string | undefined;
|
|
48
|
+
/** The activities the agent reported, in order, with plan steps and agent entries where they map. */
|
|
49
|
+
activities: import("./ag-ui.js").AgUiActivity[];
|
|
44
50
|
/** Forget the conversation and start a new thread. */
|
|
45
51
|
reset: () => void;
|
|
46
52
|
composer: {
|
|
@@ -16,7 +16,7 @@ import { fromAgUiEvents } from "./ag-ui.js";
|
|
|
16
16
|
* Mentions are sent as structured `forwardedProps.references`, not pasted into the text.
|
|
17
17
|
*/
|
|
18
18
|
export function useAgUi(options) {
|
|
19
|
-
const { url, headers, agent, toolTitle, fetch: fetcher, threadId, forwardedProps, tools, context } = options;
|
|
19
|
+
const { url, headers, agent, agents, toolTitle, fetch: fetcher, threadId, forwardedProps, tools, context, initialState } = options;
|
|
20
20
|
const [events, setEvents] = React.useState([]);
|
|
21
21
|
const [running, setRunning] = React.useState(false);
|
|
22
22
|
const [error, setError] = React.useState();
|
|
@@ -27,7 +27,7 @@ export function useAgUi(options) {
|
|
|
27
27
|
const run = React.useRef(null);
|
|
28
28
|
const client = React.useRef(null);
|
|
29
29
|
const answers = React.useRef(new Map());
|
|
30
|
-
const state = React.useMemo(() => fromAgUiEvents(events, { agent, toolTitle, running, now: new Date(now) }), [events, agent, toolTitle, running, now]);
|
|
30
|
+
const state = React.useMemo(() => fromAgUiEvents(events, { agent, agents, toolTitle, running, now: new Date(now), initialState }), [events, agent, agents, toolTitle, running, now, initialState]);
|
|
31
31
|
const nextExpiry = state.approvals.reduce((soonest, a) => {
|
|
32
32
|
const at = a.expiresAt ? Date.parse(a.expiresAt) : NaN;
|
|
33
33
|
return Number.isNaN(at) ? soonest : Math.min(soonest ?? at, at);
|
|
@@ -42,7 +42,13 @@ export function useAgUi(options) {
|
|
|
42
42
|
React.useEffect(() => () => run.current?.abort(), []);
|
|
43
43
|
const getClient = React.useCallback(() => {
|
|
44
44
|
if (!client.current)
|
|
45
|
-
client.current = new HttpAgent({
|
|
45
|
+
client.current = new HttpAgent({
|
|
46
|
+
url,
|
|
47
|
+
...(headers && { headers }),
|
|
48
|
+
...(fetcher && { fetch: fetcher }),
|
|
49
|
+
...(threadId !== undefined && { threadId }),
|
|
50
|
+
...(initialState !== undefined && { initialState }),
|
|
51
|
+
});
|
|
46
52
|
const http = client.current;
|
|
47
53
|
// The latest options win, without losing the thread.
|
|
48
54
|
http.url = url;
|
|
@@ -50,7 +56,7 @@ export function useAgUi(options) {
|
|
|
50
56
|
if (fetcher)
|
|
51
57
|
http.fetch = fetcher;
|
|
52
58
|
return http;
|
|
53
|
-
}, [url, headers, fetcher, threadId]);
|
|
59
|
+
}, [url, headers, fetcher, threadId, initialState]);
|
|
54
60
|
const start = React.useCallback(async (http, params, before) => {
|
|
55
61
|
const controller = new AbortController();
|
|
56
62
|
run.current = controller;
|
|
@@ -66,7 +72,13 @@ export function useAgUi(options) {
|
|
|
66
72
|
setEvents((e) => [...e, ...batch]);
|
|
67
73
|
};
|
|
68
74
|
try {
|
|
69
|
-
await http.runAgent({
|
|
75
|
+
await http.runAgent({
|
|
76
|
+
...(params.resume && { resume: params.resume }),
|
|
77
|
+
...(tools && { tools }),
|
|
78
|
+
...(context && { context }),
|
|
79
|
+
forwardedProps: { ...forwardedProps, references: params.references ?? [] },
|
|
80
|
+
abortController: controller,
|
|
81
|
+
}, {
|
|
70
82
|
onEvent: ({ event }) => {
|
|
71
83
|
pending.events.push(event);
|
|
72
84
|
pending.frame ||= requestAnimationFrame(flush);
|
|
@@ -134,6 +146,12 @@ export function useAgUi(options) {
|
|
|
134
146
|
/** The request failed; say so, and let the person send again. */
|
|
135
147
|
error,
|
|
136
148
|
threadId: state.threadId,
|
|
149
|
+
/** The agent's shared state (STATE_SNAPSHOT/STATE_DELTA); undefined until it sends one. */
|
|
150
|
+
state: state.state,
|
|
151
|
+
/** Set, with the reason, when a state update couldn't be applied and `state` may be behind. */
|
|
152
|
+
stateOutOfDate: state.stateOutOfDate,
|
|
153
|
+
/** The activities the agent reported, in order, with plan steps and agent entries where they map. */
|
|
154
|
+
activities: state.activities,
|
|
137
155
|
/** Forget the conversation and start a new thread. */
|
|
138
156
|
reset: () => {
|
|
139
157
|
run.current?.abort();
|