@pikku/assistant-ui 0.12.7 → 0.12.8
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/CHANGELOG.md +6 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -2
- package/dist/cjs/pikku-agent-chat.js +22 -10
- package/dist/cjs/use-pikku-agent-runtime.d.ts +12 -11
- package/dist/cjs/use-pikku-agent-runtime.js +160 -615
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/pikku-agent-chat.js +11 -8
- package/dist/esm/use-pikku-agent-runtime.d.ts +12 -11
- package/dist/esm/use-pikku-agent-runtime.js +181 -595
- package/package.json +4 -2
- package/src/index.ts +0 -1
- package/src/pikku-agent-chat.tsx +14 -8
- package/src/use-pikku-agent-runtime.ts +219 -803
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
4
6
|
useMemo,
|
|
5
7
|
useRef,
|
|
6
8
|
useState,
|
|
7
|
-
useCallback,
|
|
8
9
|
} from 'react'
|
|
10
|
+
import { HttpAgent } from '@ag-ui/client'
|
|
11
|
+
import type {
|
|
12
|
+
RunAgentInput,
|
|
13
|
+
BaseEvent,
|
|
14
|
+
CustomEvent as AgUiCustomEvent,
|
|
15
|
+
} from '@ag-ui/client'
|
|
16
|
+
import type { Observable } from 'rxjs'
|
|
17
|
+
import { useAgUiRuntime } from '@assistant-ui/react-ag-ui'
|
|
9
18
|
import {
|
|
10
|
-
|
|
11
|
-
type ChatModelAdapter,
|
|
19
|
+
ExportedMessageRepository,
|
|
12
20
|
type ThreadMessageLike,
|
|
21
|
+
type ThreadHistoryAdapter,
|
|
13
22
|
} from '@assistant-ui/react'
|
|
14
23
|
|
|
15
24
|
export interface PikkuAgentRuntimeOptions {
|
|
@@ -17,7 +26,6 @@ export interface PikkuAgentRuntimeOptions {
|
|
|
17
26
|
agentName: string
|
|
18
27
|
threadId: string
|
|
19
28
|
resourceId: string
|
|
20
|
-
initialMessages?: any[]
|
|
21
29
|
onFinish?: () => void
|
|
22
30
|
credentials?: RequestCredentials
|
|
23
31
|
headers?: Record<string, string>
|
|
@@ -27,6 +35,11 @@ export interface PikkuAgentRuntimeOptions {
|
|
|
27
35
|
* Provide upfront state (e.g. current org/project/branch/deployment IDs)
|
|
28
36
|
* so the agent can call tools without asking the user. */
|
|
29
37
|
context?: string
|
|
38
|
+
/** Prior messages to hydrate the thread with (e.g. converted from persisted
|
|
39
|
+
* DB history via `convertDbMessages`). Loaded once on mount, so the
|
|
40
|
+
* consumer must keep the chat unmounted until these are available (key or
|
|
41
|
+
* gate on load) — the runtime does not re-hydrate when they change later. */
|
|
42
|
+
initialMessages?: ThreadMessageLike[]
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export interface PendingApproval {
|
|
@@ -34,7 +47,7 @@ export interface PendingApproval {
|
|
|
34
47
|
toolName: string
|
|
35
48
|
args: unknown
|
|
36
49
|
reason?: string
|
|
37
|
-
runId
|
|
50
|
+
runId?: string
|
|
38
51
|
type?: 'approval-request' | 'credential-request'
|
|
39
52
|
credentialName?: string
|
|
40
53
|
credentialType?: 'oauth2' | 'apikey'
|
|
@@ -43,179 +56,19 @@ export interface PendingApproval {
|
|
|
43
56
|
|
|
44
57
|
export interface PikkuApprovalContextValue {
|
|
45
58
|
pendingApprovals: PendingApproval[]
|
|
46
|
-
|
|
59
|
+
/** Resolve an approval/credential request. Returns `true` when the request
|
|
60
|
+
* was found and acknowledged — callers must gate their `addResult` call on
|
|
61
|
+
* this so a stray result can't start a resume run with nothing queued. */
|
|
62
|
+
handleApproval: (toolCallId: string, approved: boolean) => Promise<boolean>
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
export const PikkuApprovalContext = createContext<PikkuApprovalContextValue>({
|
|
50
66
|
pendingApprovals: [],
|
|
51
|
-
handleApproval: () =>
|
|
67
|
+
handleApproval: async () => false,
|
|
52
68
|
})
|
|
53
69
|
|
|
54
70
|
export const usePikkuApproval = () => useContext(PikkuApprovalContext)
|
|
55
71
|
|
|
56
|
-
async function* parseSSEStream(
|
|
57
|
-
reader: ReadableStreamDefaultReader<Uint8Array>
|
|
58
|
-
) {
|
|
59
|
-
const decoder = new TextDecoder()
|
|
60
|
-
let buffer = ''
|
|
61
|
-
|
|
62
|
-
while (true) {
|
|
63
|
-
const { done, value } = await reader.read()
|
|
64
|
-
if (done) break
|
|
65
|
-
|
|
66
|
-
buffer += decoder.decode(value, { stream: true })
|
|
67
|
-
const lines = buffer.split('\n')
|
|
68
|
-
buffer = lines.pop()!
|
|
69
|
-
|
|
70
|
-
for (const line of lines) {
|
|
71
|
-
if (line.startsWith('data: ')) {
|
|
72
|
-
const data = line.slice(6)
|
|
73
|
-
if (data) {
|
|
74
|
-
try {
|
|
75
|
-
yield JSON.parse(data)
|
|
76
|
-
} catch {
|
|
77
|
-
// skip unparseable lines
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
type ToolCall = {
|
|
86
|
-
type: 'tool-call'
|
|
87
|
-
toolCallId: string
|
|
88
|
-
toolName: string
|
|
89
|
-
args: Record<string, unknown>
|
|
90
|
-
result?: string
|
|
91
|
-
isError?: boolean
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
type StructuredPart =
|
|
95
|
-
| {
|
|
96
|
-
type: 'generative-ui'
|
|
97
|
-
spec: unknown
|
|
98
|
-
}
|
|
99
|
-
| {
|
|
100
|
-
type: 'data'
|
|
101
|
-
name: string
|
|
102
|
-
data: unknown
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Shared helper: consume an SSE stream and populate text/toolCalls.
|
|
107
|
-
* Returns an array of PendingApprovals when the stream requests them, or empty when done.
|
|
108
|
-
*/
|
|
109
|
-
async function processStream(
|
|
110
|
-
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
111
|
-
text: { value: string },
|
|
112
|
-
toolCalls: ToolCall[],
|
|
113
|
-
structuredParts: StructuredPart[],
|
|
114
|
-
yieldContent: () => void,
|
|
115
|
-
onFinish?: () => void
|
|
116
|
-
): Promise<PendingApproval[]> {
|
|
117
|
-
const pendingApprovals: PendingApproval[] = []
|
|
118
|
-
|
|
119
|
-
for await (const event of parseSSEStream(reader)) {
|
|
120
|
-
switch (event.type) {
|
|
121
|
-
case 'text-delta':
|
|
122
|
-
text.value += event.text
|
|
123
|
-
break
|
|
124
|
-
case 'tool-call': {
|
|
125
|
-
let parsedArgs = event.args
|
|
126
|
-
if (typeof event.args === 'string') {
|
|
127
|
-
try {
|
|
128
|
-
parsedArgs = JSON.parse(event.args)
|
|
129
|
-
} catch {
|
|
130
|
-
parsedArgs = {}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
toolCalls.push({
|
|
134
|
-
type: 'tool-call',
|
|
135
|
-
toolCallId: event.toolCallId,
|
|
136
|
-
toolName: event.toolName,
|
|
137
|
-
args: parsedArgs,
|
|
138
|
-
})
|
|
139
|
-
break
|
|
140
|
-
}
|
|
141
|
-
case 'tool-result': {
|
|
142
|
-
// Skip tool-results that contain __approvalRequired
|
|
143
|
-
const resultObj = typeof event.result === 'object' ? event.result : null
|
|
144
|
-
if (resultObj && '__approvalRequired' in resultObj) {
|
|
145
|
-
break
|
|
146
|
-
}
|
|
147
|
-
const tc = toolCalls.find((t) => t.toolCallId === event.toolCallId)
|
|
148
|
-
if (tc) {
|
|
149
|
-
tc.result =
|
|
150
|
-
typeof event.result === 'string'
|
|
151
|
-
? event.result
|
|
152
|
-
: JSON.stringify(event.result)
|
|
153
|
-
if (event.isError) {
|
|
154
|
-
tc.isError = true
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
break
|
|
158
|
-
}
|
|
159
|
-
case 'generative-ui': {
|
|
160
|
-
const nextPart = {
|
|
161
|
-
type: 'generative-ui' as const,
|
|
162
|
-
spec: event.spec,
|
|
163
|
-
}
|
|
164
|
-
const existingIndex = structuredParts.findIndex(
|
|
165
|
-
(part) => part.type === 'generative-ui'
|
|
166
|
-
)
|
|
167
|
-
if (existingIndex === -1) structuredParts.push(nextPart)
|
|
168
|
-
else structuredParts[existingIndex] = nextPart
|
|
169
|
-
break
|
|
170
|
-
}
|
|
171
|
-
case 'data': {
|
|
172
|
-
const nextPart = {
|
|
173
|
-
type: 'data' as const,
|
|
174
|
-
name: event.name,
|
|
175
|
-
data: event.data,
|
|
176
|
-
}
|
|
177
|
-
const existingIndex = structuredParts.findIndex(
|
|
178
|
-
(part) => part.type === 'data' && part.name === event.name
|
|
179
|
-
)
|
|
180
|
-
if (existingIndex === -1) structuredParts.push(nextPart)
|
|
181
|
-
else structuredParts[existingIndex] = nextPart
|
|
182
|
-
break
|
|
183
|
-
}
|
|
184
|
-
case 'approval-request':
|
|
185
|
-
pendingApprovals.push({
|
|
186
|
-
toolCallId: event.toolCallId,
|
|
187
|
-
toolName: event.toolName,
|
|
188
|
-
args: event.args,
|
|
189
|
-
reason: event.reason,
|
|
190
|
-
runId: event.runId,
|
|
191
|
-
type: 'approval-request',
|
|
192
|
-
})
|
|
193
|
-
break
|
|
194
|
-
case 'credential-request':
|
|
195
|
-
pendingApprovals.push({
|
|
196
|
-
toolCallId: event.toolCallId,
|
|
197
|
-
toolName: event.toolName,
|
|
198
|
-
args: event.args,
|
|
199
|
-
runId: event.runId,
|
|
200
|
-
type: 'credential-request',
|
|
201
|
-
credentialName: event.credentialName,
|
|
202
|
-
credentialType: event.credentialType,
|
|
203
|
-
connectUrl: event.connectUrl,
|
|
204
|
-
})
|
|
205
|
-
break
|
|
206
|
-
case 'error':
|
|
207
|
-
text.value += `\n\nError: ${event.message || event.errorText || 'Unknown error'}`
|
|
208
|
-
break
|
|
209
|
-
case 'done':
|
|
210
|
-
onFinish?.()
|
|
211
|
-
continue
|
|
212
|
-
}
|
|
213
|
-
yieldContent()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return pendingApprovals
|
|
217
|
-
}
|
|
218
|
-
|
|
219
72
|
export function isDeniedResult(result: unknown): boolean {
|
|
220
73
|
if (result == null) return false
|
|
221
74
|
try {
|
|
@@ -278,341 +131,6 @@ export function resolvePikkuToolStatus(
|
|
|
278
131
|
return { type: 'completed' }
|
|
279
132
|
}
|
|
280
133
|
|
|
281
|
-
function buildRichContent(
|
|
282
|
-
text: { value: string },
|
|
283
|
-
structuredParts: StructuredPart[],
|
|
284
|
-
toolCalls: ToolCall[]
|
|
285
|
-
): any[] {
|
|
286
|
-
const content: any[] = []
|
|
287
|
-
if (text.value) content.push({ type: 'text' as const, text: text.value })
|
|
288
|
-
content.push(...structuredParts)
|
|
289
|
-
content.push(...toolCalls)
|
|
290
|
-
return content
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function buildContentFromAgentResult(result: unknown): any[] {
|
|
294
|
-
const content: any[] = []
|
|
295
|
-
|
|
296
|
-
if (typeof result === 'string') {
|
|
297
|
-
if (result) content.push({ type: 'text' as const, text: result })
|
|
298
|
-
return content
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (!result || typeof result !== 'object') return content
|
|
302
|
-
|
|
303
|
-
const record = result as Record<string, unknown>
|
|
304
|
-
if (typeof record.text === 'string' && record.text) {
|
|
305
|
-
content.push({ type: 'text' as const, text: record.text })
|
|
306
|
-
}
|
|
307
|
-
if (record.ui != null) {
|
|
308
|
-
content.push({ type: 'generative-ui' as const, spec: record.ui })
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return content
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function createPikkuStreamingAdapter(
|
|
315
|
-
optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
|
|
316
|
-
pendingApprovalsRef: React.RefObject<PendingApproval[]>,
|
|
317
|
-
approvalDecisionsRef: React.RefObject<
|
|
318
|
-
{ toolCallId: string; approved: boolean }[]
|
|
319
|
-
>,
|
|
320
|
-
setPendingApprovalsRef: React.RefObject<
|
|
321
|
-
(approvals: PendingApproval[]) => void
|
|
322
|
-
>,
|
|
323
|
-
onFinishRef: React.RefObject<(() => void) | undefined>
|
|
324
|
-
): ChatModelAdapter {
|
|
325
|
-
return {
|
|
326
|
-
async *run({ messages, abortSignal }) {
|
|
327
|
-
const opts = optionsRef.current!
|
|
328
|
-
|
|
329
|
-
// Check if this run() is a continuation after approval decisions.
|
|
330
|
-
// assistant-ui calls run() again after addResult provides tool results for ALL tool calls.
|
|
331
|
-
const pendingApprovals = pendingApprovalsRef.current
|
|
332
|
-
if (pendingApprovals.length > 0) {
|
|
333
|
-
// Read the decisions accumulated by handleApproval() from click handlers.
|
|
334
|
-
const decisions = approvalDecisionsRef.current
|
|
335
|
-
approvalDecisionsRef.current = []
|
|
336
|
-
|
|
337
|
-
if (decisions.length === 0) {
|
|
338
|
-
// No decisions set — shouldn't happen if composer is disabled.
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Clear pending approvals state
|
|
343
|
-
pendingApprovalsRef.current = []
|
|
344
|
-
setPendingApprovalsRef.current([])
|
|
345
|
-
|
|
346
|
-
// Send /resume for each decision sequentially.
|
|
347
|
-
// All but the last resume will return quickly (just tool-result + done).
|
|
348
|
-
// The last resume triggers continuation (next LLM step).
|
|
349
|
-
let lastText = { value: '' }
|
|
350
|
-
let lastToolCalls: ToolCall[] = []
|
|
351
|
-
let lastStructuredParts: StructuredPart[] = []
|
|
352
|
-
let nextApprovals: PendingApproval[] = []
|
|
353
|
-
|
|
354
|
-
for (let i = 0; i < decisions.length; i++) {
|
|
355
|
-
const decision = decisions[i]
|
|
356
|
-
// Find the runId from the matching pending approval
|
|
357
|
-
const matchingApproval = pendingApprovals.find(
|
|
358
|
-
(p) => p.toolCallId === decision.toolCallId
|
|
359
|
-
)
|
|
360
|
-
const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
|
|
361
|
-
|
|
362
|
-
const resumeResponse = await fetch(
|
|
363
|
-
`${opts.api}/${opts.agentName}/resume`,
|
|
364
|
-
{
|
|
365
|
-
method: 'POST',
|
|
366
|
-
headers: {
|
|
367
|
-
'Content-Type': 'application/json',
|
|
368
|
-
...opts.headers,
|
|
369
|
-
},
|
|
370
|
-
body: JSON.stringify({
|
|
371
|
-
runId,
|
|
372
|
-
toolCallId: decision.toolCallId,
|
|
373
|
-
approved: decision.approved,
|
|
374
|
-
}),
|
|
375
|
-
signal: abortSignal,
|
|
376
|
-
credentials: opts.credentials,
|
|
377
|
-
}
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
if (!resumeResponse.ok || !resumeResponse.body) {
|
|
381
|
-
const errorText = resumeResponse.body
|
|
382
|
-
? await resumeResponse.text().catch(() => '')
|
|
383
|
-
: ''
|
|
384
|
-
throw new Error(
|
|
385
|
-
`Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
|
|
386
|
-
)
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const text = { value: '' }
|
|
390
|
-
const toolCalls: ToolCall[] = []
|
|
391
|
-
const structuredParts: StructuredPart[] = []
|
|
392
|
-
const reader = resumeResponse.body.getReader()
|
|
393
|
-
const streamApprovals = await processStream(
|
|
394
|
-
reader,
|
|
395
|
-
text,
|
|
396
|
-
toolCalls,
|
|
397
|
-
structuredParts,
|
|
398
|
-
() => {},
|
|
399
|
-
i === decisions.length - 1
|
|
400
|
-
? (onFinishRef.current ?? undefined)
|
|
401
|
-
: undefined
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
// Keep the last resume's output (it has continuation content)
|
|
405
|
-
lastText = text
|
|
406
|
-
lastToolCalls = toolCalls
|
|
407
|
-
lastStructuredParts = structuredParts
|
|
408
|
-
if (streamApprovals.length > 0) {
|
|
409
|
-
nextApprovals = streamApprovals
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Build content from the last resume's output
|
|
414
|
-
const content = buildRichContent(
|
|
415
|
-
lastText,
|
|
416
|
-
lastStructuredParts,
|
|
417
|
-
lastToolCalls
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
if (nextApprovals.length > 0) {
|
|
421
|
-
// More approvals from continuation — show them
|
|
422
|
-
pendingApprovalsRef.current = nextApprovals
|
|
423
|
-
setPendingApprovalsRef.current(nextApprovals)
|
|
424
|
-
|
|
425
|
-
// Add approval tool calls to content
|
|
426
|
-
for (const approval of nextApprovals) {
|
|
427
|
-
const approvalToolCall: ToolCall = {
|
|
428
|
-
type: 'tool-call',
|
|
429
|
-
toolCallId: approval.toolCallId,
|
|
430
|
-
toolName: approval.toolName,
|
|
431
|
-
args: {
|
|
432
|
-
...(typeof approval.args === 'object' && approval.args !== null
|
|
433
|
-
? (approval.args as Record<string, unknown>)
|
|
434
|
-
: {}),
|
|
435
|
-
...(approval.reason
|
|
436
|
-
? { __approvalReason: approval.reason }
|
|
437
|
-
: {}),
|
|
438
|
-
},
|
|
439
|
-
}
|
|
440
|
-
const idx = lastToolCalls.findIndex(
|
|
441
|
-
(tc) => tc.toolCallId === approval.toolCallId && !tc.result
|
|
442
|
-
)
|
|
443
|
-
if (idx !== -1) {
|
|
444
|
-
lastToolCalls[idx] = approvalToolCall
|
|
445
|
-
} else {
|
|
446
|
-
lastToolCalls.push(approvalToolCall)
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const updatedContent = buildRichContent(
|
|
451
|
-
lastText,
|
|
452
|
-
lastStructuredParts,
|
|
453
|
-
lastToolCalls
|
|
454
|
-
)
|
|
455
|
-
yield {
|
|
456
|
-
content: updatedContent,
|
|
457
|
-
status: {
|
|
458
|
-
type: 'requires-action' as const,
|
|
459
|
-
reason: 'tool-calls' as const,
|
|
460
|
-
},
|
|
461
|
-
}
|
|
462
|
-
} else if (content.length > 0) {
|
|
463
|
-
yield { content }
|
|
464
|
-
}
|
|
465
|
-
return
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Normal flow: new user message → stream
|
|
469
|
-
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
470
|
-
if (!lastUserMsg) return
|
|
471
|
-
|
|
472
|
-
let messageText = ''
|
|
473
|
-
if (lastUserMsg.content) {
|
|
474
|
-
for (const part of lastUserMsg.content) {
|
|
475
|
-
if ('text' in part && part.type === 'text') {
|
|
476
|
-
messageText += (part as { text: string }).text
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
let response: Response
|
|
482
|
-
try {
|
|
483
|
-
response = await fetch(`${opts.api}/${opts.agentName}/stream`, {
|
|
484
|
-
method: 'POST',
|
|
485
|
-
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
|
486
|
-
body: JSON.stringify({
|
|
487
|
-
agentName: opts.agentName,
|
|
488
|
-
message: messageText,
|
|
489
|
-
threadId: opts.threadId,
|
|
490
|
-
resourceId: opts.resourceId,
|
|
491
|
-
model: opts.model,
|
|
492
|
-
temperature: opts.temperature,
|
|
493
|
-
...(opts.context ? { context: opts.context } : {}),
|
|
494
|
-
}),
|
|
495
|
-
signal: abortSignal,
|
|
496
|
-
credentials: opts.credentials,
|
|
497
|
-
})
|
|
498
|
-
} catch (e: any) {
|
|
499
|
-
const msg = e?.message || 'Unknown error'
|
|
500
|
-
let errorText = 'Failed to connect to the agent.'
|
|
501
|
-
if (
|
|
502
|
-
msg.includes('Failed to fetch') ||
|
|
503
|
-
msg.includes('NetworkError') ||
|
|
504
|
-
msg.includes('CORS')
|
|
505
|
-
) {
|
|
506
|
-
errorText =
|
|
507
|
-
'Unable to reach the agent server. The deployment may be down or the URL may be incorrect.'
|
|
508
|
-
} else if (msg.includes('abort')) {
|
|
509
|
-
return
|
|
510
|
-
}
|
|
511
|
-
yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (!response.ok || !response.body) {
|
|
516
|
-
let errorText = `Request failed (${response.status}).`
|
|
517
|
-
if (response.status === 401 || response.status === 403) {
|
|
518
|
-
errorText = 'Authentication error.'
|
|
519
|
-
} else if (response.status === 404) {
|
|
520
|
-
errorText =
|
|
521
|
-
'Agent not found — the agent may not be configured for this project.'
|
|
522
|
-
} else if (response.status === 502 || response.status === 503) {
|
|
523
|
-
errorText =
|
|
524
|
-
'The agent server is currently unavailable. Try again in a moment.'
|
|
525
|
-
} else if (response.status === 429) {
|
|
526
|
-
errorText = 'Rate limited — too many requests. Please wait a moment.'
|
|
527
|
-
}
|
|
528
|
-
yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
|
|
529
|
-
return
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const text = { value: '' }
|
|
533
|
-
const toolCalls: ToolCall[] = []
|
|
534
|
-
const structuredParts: StructuredPart[] = []
|
|
535
|
-
let pendingContent: any[] | null = null
|
|
536
|
-
const yieldContent = () => {
|
|
537
|
-
const content = buildRichContent(text, structuredParts, toolCalls)
|
|
538
|
-
if (content.length > 0) pendingContent = content
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const reader = response.body.getReader()
|
|
542
|
-
const approvals = await processStream(
|
|
543
|
-
reader,
|
|
544
|
-
text,
|
|
545
|
-
toolCalls,
|
|
546
|
-
structuredParts,
|
|
547
|
-
yieldContent,
|
|
548
|
-
onFinishRef.current ?? undefined
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
if (approvals.length === 0) {
|
|
552
|
-
// No approval needed — yield final content and done
|
|
553
|
-
if (pendingContent) {
|
|
554
|
-
yield { content: pendingContent }
|
|
555
|
-
}
|
|
556
|
-
return
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Approvals requested: store them for the next run() call
|
|
560
|
-
pendingApprovalsRef.current = approvals
|
|
561
|
-
setPendingApprovalsRef.current(approvals)
|
|
562
|
-
|
|
563
|
-
// Each approval tool call needs to be shown without a result
|
|
564
|
-
// so assistant-ui renders them as requires-action
|
|
565
|
-
for (const approval of approvals) {
|
|
566
|
-
const approvalToolCall: ToolCall = {
|
|
567
|
-
type: 'tool-call',
|
|
568
|
-
toolCallId: approval.toolCallId,
|
|
569
|
-
toolName: approval.toolName,
|
|
570
|
-
args: {
|
|
571
|
-
...(typeof approval.args === 'object' && approval.args !== null
|
|
572
|
-
? (approval.args as Record<string, unknown>)
|
|
573
|
-
: {}),
|
|
574
|
-
...(approval.reason ? { __approvalReason: approval.reason } : {}),
|
|
575
|
-
},
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Replace the existing tool call (if any) with the approval version
|
|
579
|
-
const parentIdx = toolCalls.findIndex(
|
|
580
|
-
(tc) => tc.toolCallId === approval.toolCallId && !tc.result
|
|
581
|
-
)
|
|
582
|
-
if (parentIdx !== -1) {
|
|
583
|
-
toolCalls[parentIdx] = approvalToolCall
|
|
584
|
-
} else {
|
|
585
|
-
toolCalls.push(approvalToolCall)
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Remove any forwarded sub-agent tool calls that duplicate approval tool names
|
|
590
|
-
const approvalToolCallIds = new Set(approvals.map((a) => a.toolCallId))
|
|
591
|
-
const approvalToolNames = new Set(approvals.map((a) => a.toolName))
|
|
592
|
-
for (let i = toolCalls.length - 1; i >= 0; i--) {
|
|
593
|
-
if (
|
|
594
|
-
approvalToolNames.has(toolCalls[i].toolName) &&
|
|
595
|
-
!approvalToolCallIds.has(toolCalls[i].toolCallId)
|
|
596
|
-
) {
|
|
597
|
-
toolCalls.splice(i, 1)
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const content = buildRichContent(text, structuredParts, toolCalls)
|
|
602
|
-
yield {
|
|
603
|
-
content,
|
|
604
|
-
status: {
|
|
605
|
-
type: 'requires-action' as const,
|
|
606
|
-
reason: 'tool-calls' as const,
|
|
607
|
-
},
|
|
608
|
-
}
|
|
609
|
-
// Generator returns here. assistant-ui will show the approval UI for each tool call.
|
|
610
|
-
// When the user clicks Approve/Deny on ALL tools → handleApproval accumulates decisions →
|
|
611
|
-
// addResult for each → run() is called again when all have results.
|
|
612
|
-
},
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
134
|
export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
|
|
617
135
|
const result: ThreadMessageLike[] = []
|
|
618
136
|
let currentAssistant: ThreadMessageLike | null = null
|
|
@@ -728,340 +246,238 @@ export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
|
|
|
728
246
|
return result
|
|
729
247
|
}
|
|
730
248
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const optionsRef = useRef(options)
|
|
737
|
-
optionsRef.current = options
|
|
738
|
-
|
|
739
|
-
const onFinishRef = useRef(options.onFinish)
|
|
740
|
-
onFinishRef.current = options.onFinish
|
|
741
|
-
|
|
742
|
-
const pendingApprovalsRef = useRef<PendingApproval[]>([])
|
|
743
|
-
const approvalDecisionsRef = useRef<
|
|
744
|
-
{ toolCallId: string; approved: boolean }[]
|
|
745
|
-
>([])
|
|
746
|
-
|
|
747
|
-
const setPendingApprovalsRef = useRef(setPendingApprovals)
|
|
748
|
-
setPendingApprovalsRef.current = setPendingApprovals
|
|
749
|
-
|
|
750
|
-
const adapter = useMemo(
|
|
751
|
-
() =>
|
|
752
|
-
createPikkuStreamingAdapter(
|
|
753
|
-
optionsRef,
|
|
754
|
-
pendingApprovalsRef,
|
|
755
|
-
approvalDecisionsRef,
|
|
756
|
-
setPendingApprovalsRef,
|
|
757
|
-
onFinishRef
|
|
758
|
-
),
|
|
759
|
-
[]
|
|
760
|
-
)
|
|
761
|
-
|
|
762
|
-
const initialMessages = useMemo(
|
|
763
|
-
() =>
|
|
764
|
-
options.initialMessages
|
|
765
|
-
? convertDbMessages(options.initialMessages)
|
|
766
|
-
: undefined,
|
|
767
|
-
[options.initialMessages]
|
|
768
|
-
)
|
|
769
|
-
|
|
770
|
-
const runtime = useLocalRuntime(adapter, { initialMessages })
|
|
771
|
-
|
|
772
|
-
// handleApproval is called from the Approve/Deny button click handler.
|
|
773
|
-
// It accumulates the decision in the ref. assistant-ui will call run()
|
|
774
|
-
// when ALL tool calls have results (via addResult).
|
|
775
|
-
const handleApproval = useCallback(
|
|
776
|
-
(toolCallId: string, approved: boolean) => {
|
|
777
|
-
approvalDecisionsRef.current.push({ toolCallId, approved })
|
|
778
|
-
},
|
|
779
|
-
[]
|
|
780
|
-
)
|
|
781
|
-
|
|
782
|
-
const isAwaitingApproval = pendingApprovals.length > 0
|
|
249
|
+
type PendingResume = {
|
|
250
|
+
runId: string
|
|
251
|
+
toolCallId: string
|
|
252
|
+
approved: boolean
|
|
253
|
+
}
|
|
783
254
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
255
|
+
function extractLastUserMessage(messages: RunAgentInput['messages']): string {
|
|
256
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
|
257
|
+
if (!lastUser) return ''
|
|
258
|
+
const content = lastUser.content
|
|
259
|
+
if (typeof content === 'string') return content
|
|
260
|
+
if (Array.isArray(content)) {
|
|
261
|
+
return content
|
|
262
|
+
.filter((p: any) => p.type === 'text')
|
|
263
|
+
.map((p: any) => (p.text as string) ?? '')
|
|
264
|
+
.join('')
|
|
789
265
|
}
|
|
266
|
+
return ''
|
|
790
267
|
}
|
|
791
268
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
async *run({ messages, abortSignal }) {
|
|
805
|
-
const opts = optionsRef.current!
|
|
806
|
-
|
|
807
|
-
// Continuation after approval decisions
|
|
808
|
-
const pendingApprovals = pendingApprovalsRef.current
|
|
809
|
-
if (pendingApprovals.length > 0) {
|
|
810
|
-
const decisions = approvalDecisionsRef.current
|
|
811
|
-
approvalDecisionsRef.current = []
|
|
812
|
-
|
|
813
|
-
if (decisions.length === 0) return
|
|
814
|
-
|
|
815
|
-
pendingApprovalsRef.current = []
|
|
816
|
-
setPendingApprovalsRef.current([])
|
|
817
|
-
|
|
818
|
-
// Resume uses SSE (same as streaming mode)
|
|
819
|
-
let lastText = { value: '' }
|
|
820
|
-
let lastToolCalls: ToolCall[] = []
|
|
821
|
-
let lastStructuredParts: StructuredPart[] = []
|
|
822
|
-
let nextApprovals: PendingApproval[] = []
|
|
823
|
-
|
|
824
|
-
for (let i = 0; i < decisions.length; i++) {
|
|
825
|
-
const decision = decisions[i]
|
|
826
|
-
const matchingApproval = pendingApprovals.find(
|
|
827
|
-
(p) => p.toolCallId === decision.toolCallId
|
|
828
|
-
)
|
|
829
|
-
const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
|
|
830
|
-
|
|
831
|
-
const resumeResponse = await fetch(
|
|
832
|
-
`${opts.api}/${opts.agentName}/resume`,
|
|
833
|
-
{
|
|
834
|
-
method: 'POST',
|
|
835
|
-
headers: {
|
|
836
|
-
'Content-Type': 'application/json',
|
|
837
|
-
...opts.headers,
|
|
838
|
-
},
|
|
839
|
-
body: JSON.stringify({
|
|
840
|
-
runId,
|
|
841
|
-
toolCallId: decision.toolCallId,
|
|
842
|
-
approved: decision.approved,
|
|
843
|
-
}),
|
|
844
|
-
signal: abortSignal,
|
|
845
|
-
credentials: opts.credentials,
|
|
846
|
-
}
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
if (!resumeResponse.ok || !resumeResponse.body) {
|
|
850
|
-
const errorText = resumeResponse.body
|
|
851
|
-
? await resumeResponse.text().catch(() => '')
|
|
852
|
-
: ''
|
|
853
|
-
throw new Error(
|
|
854
|
-
`Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
|
|
855
|
-
)
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
const text = { value: '' }
|
|
859
|
-
const toolCalls: ToolCall[] = []
|
|
860
|
-
const structuredParts: StructuredPart[] = []
|
|
861
|
-
const reader = resumeResponse.body.getReader()
|
|
862
|
-
const streamApprovals = await processStream(
|
|
863
|
-
reader,
|
|
864
|
-
text,
|
|
865
|
-
toolCalls,
|
|
866
|
-
structuredParts,
|
|
867
|
-
() => {},
|
|
868
|
-
i === decisions.length - 1
|
|
869
|
-
? (onFinishRef.current ?? undefined)
|
|
870
|
-
: undefined
|
|
871
|
-
)
|
|
872
|
-
|
|
873
|
-
lastText = text
|
|
874
|
-
lastToolCalls = toolCalls
|
|
875
|
-
lastStructuredParts = structuredParts
|
|
876
|
-
if (streamApprovals.length > 0) {
|
|
877
|
-
nextApprovals = streamApprovals
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const content = buildRichContent(
|
|
882
|
-
lastText,
|
|
883
|
-
lastStructuredParts,
|
|
884
|
-
lastToolCalls
|
|
885
|
-
)
|
|
886
|
-
|
|
887
|
-
if (nextApprovals.length > 0) {
|
|
888
|
-
pendingApprovalsRef.current = nextApprovals
|
|
889
|
-
setPendingApprovalsRef.current(nextApprovals)
|
|
269
|
+
class PikkuAgent extends HttpAgent {
|
|
270
|
+
private pikkuOpts: PikkuAgentRuntimeOptions
|
|
271
|
+
private _pendingResume: PendingResume | null = null
|
|
272
|
+
private _currentResume: PendingResume | null = null
|
|
273
|
+
|
|
274
|
+
constructor(opts: PikkuAgentRuntimeOptions) {
|
|
275
|
+
super({
|
|
276
|
+
url: `${opts.api}/${opts.agentName}/stream`,
|
|
277
|
+
threadId: opts.threadId,
|
|
278
|
+
})
|
|
279
|
+
this.pikkuOpts = opts
|
|
280
|
+
}
|
|
890
281
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
toolCallId: approval.toolCallId,
|
|
895
|
-
toolName: approval.toolName,
|
|
896
|
-
args: {
|
|
897
|
-
...(typeof approval.args === 'object' && approval.args !== null
|
|
898
|
-
? (approval.args as Record<string, unknown>)
|
|
899
|
-
: {}),
|
|
900
|
-
...(approval.reason
|
|
901
|
-
? { __approvalReason: approval.reason }
|
|
902
|
-
: {}),
|
|
903
|
-
},
|
|
904
|
-
}
|
|
905
|
-
const idx = lastToolCalls.findIndex(
|
|
906
|
-
(tc) => tc.toolCallId === approval.toolCallId && !tc.result
|
|
907
|
-
)
|
|
908
|
-
if (idx !== -1) {
|
|
909
|
-
lastToolCalls[idx] = approvalToolCall
|
|
910
|
-
} else {
|
|
911
|
-
lastToolCalls.push(approvalToolCall)
|
|
912
|
-
}
|
|
913
|
-
}
|
|
282
|
+
updateOpts(opts: PikkuAgentRuntimeOptions) {
|
|
283
|
+
this.pikkuOpts = opts
|
|
284
|
+
}
|
|
914
285
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
lastToolCalls
|
|
919
|
-
)
|
|
920
|
-
yield {
|
|
921
|
-
content: updatedContent,
|
|
922
|
-
status: {
|
|
923
|
-
type: 'requires-action' as const,
|
|
924
|
-
reason: 'tool-calls' as const,
|
|
925
|
-
},
|
|
926
|
-
}
|
|
927
|
-
} else if (content.length > 0) {
|
|
928
|
-
yield { content }
|
|
929
|
-
}
|
|
930
|
-
return
|
|
931
|
-
}
|
|
286
|
+
queueResume(data: PendingResume) {
|
|
287
|
+
this._pendingResume = data
|
|
288
|
+
}
|
|
932
289
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
290
|
+
run(input: RunAgentInput): Observable<BaseEvent> {
|
|
291
|
+
const resume = this._pendingResume
|
|
292
|
+
this._pendingResume = null
|
|
293
|
+
this._currentResume = resume
|
|
294
|
+
this.url = resume
|
|
295
|
+
? `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/resume`
|
|
296
|
+
: `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/stream`
|
|
297
|
+
return super.run(input)
|
|
298
|
+
}
|
|
936
299
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
300
|
+
protected requestInit(input: RunAgentInput): RequestInit {
|
|
301
|
+
const base = super.requestInit(input)
|
|
302
|
+
const resume = this._currentResume
|
|
303
|
+
const opts = this.pikkuOpts
|
|
304
|
+
const headers: Record<string, string> = {
|
|
305
|
+
'Content-Type': 'application/json',
|
|
306
|
+
Accept: 'text/event-stream',
|
|
307
|
+
...opts.headers,
|
|
308
|
+
}
|
|
945
309
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
agentName: opts.agentName,
|
|
951
|
-
message: messageText,
|
|
952
|
-
threadId: opts.threadId,
|
|
953
|
-
resourceId: opts.resourceId,
|
|
954
|
-
model: opts.model,
|
|
955
|
-
temperature: opts.temperature,
|
|
956
|
-
...(opts.context ? { context: opts.context } : {}),
|
|
957
|
-
}),
|
|
958
|
-
signal: abortSignal,
|
|
310
|
+
if (resume) {
|
|
311
|
+
return {
|
|
312
|
+
...base,
|
|
313
|
+
headers,
|
|
959
314
|
credentials: opts.credentials,
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
if (!response.ok) {
|
|
963
|
-
throw new Error(`Agent run failed: ${response.status}`)
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
const json = await response.json()
|
|
967
|
-
|
|
968
|
-
if (json.status === 'suspended' && json.pendingApprovals?.length > 0) {
|
|
969
|
-
const approvals: PendingApproval[] = json.pendingApprovals
|
|
970
|
-
pendingApprovalsRef.current = approvals
|
|
971
|
-
setPendingApprovalsRef.current(approvals)
|
|
972
|
-
|
|
973
|
-
const toolCalls: ToolCall[] = approvals.map((approval) => ({
|
|
974
|
-
type: 'tool-call' as const,
|
|
975
|
-
toolCallId: approval.toolCallId,
|
|
976
|
-
toolName: approval.toolName,
|
|
977
|
-
args: {
|
|
978
|
-
...(typeof approval.args === 'object' && approval.args !== null
|
|
979
|
-
? (approval.args as Record<string, unknown>)
|
|
980
|
-
: {}),
|
|
981
|
-
...(approval.reason ? { __approvalReason: approval.reason } : {}),
|
|
982
|
-
},
|
|
983
|
-
}))
|
|
984
|
-
|
|
985
|
-
const content: any[] = []
|
|
986
|
-
content.push(...buildContentFromAgentResult(json.result))
|
|
987
|
-
content.push(...toolCalls)
|
|
988
|
-
|
|
989
|
-
yield {
|
|
990
|
-
content,
|
|
991
|
-
status: {
|
|
992
|
-
type: 'requires-action' as const,
|
|
993
|
-
reason: 'tool-calls' as const,
|
|
994
|
-
},
|
|
995
|
-
}
|
|
996
|
-
return
|
|
315
|
+
body: JSON.stringify(resume),
|
|
997
316
|
}
|
|
317
|
+
}
|
|
998
318
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
319
|
+
return {
|
|
320
|
+
...base,
|
|
321
|
+
headers,
|
|
322
|
+
credentials: opts.credentials,
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
agentName: opts.agentName,
|
|
325
|
+
message: extractLastUserMessage(input.messages),
|
|
326
|
+
threadId: opts.threadId,
|
|
327
|
+
resourceId: opts.resourceId,
|
|
328
|
+
model: opts.model,
|
|
329
|
+
temperature: opts.temperature,
|
|
330
|
+
...(opts.context ? { context: opts.context } : {}),
|
|
331
|
+
}),
|
|
332
|
+
}
|
|
1006
333
|
}
|
|
1007
334
|
}
|
|
1008
335
|
|
|
1009
|
-
export function
|
|
1010
|
-
options: PikkuAgentRuntimeOptions
|
|
1011
|
-
) {
|
|
336
|
+
export function usePikkuAgentRuntime(options: PikkuAgentRuntimeOptions) {
|
|
1012
337
|
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
|
|
1013
338
|
[]
|
|
1014
339
|
)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
340
|
+
// Authoritative pending list, mutated synchronously so two approval clicks
|
|
341
|
+
// in the same tick can't both read a stale "remaining" and skip the resume.
|
|
342
|
+
// State mirrors it purely for rendering.
|
|
343
|
+
const pendingApprovalsRef = useRef<PendingApproval[]>([])
|
|
344
|
+
const commitPending = useCallback((next: PendingApproval[]) => {
|
|
345
|
+
pendingApprovalsRef.current = next
|
|
346
|
+
setPendingApprovals(next)
|
|
347
|
+
}, [])
|
|
1018
348
|
|
|
1019
349
|
const onFinishRef = useRef(options.onFinish)
|
|
1020
350
|
onFinishRef.current = options.onFinish
|
|
1021
351
|
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
const setPendingApprovalsRef = useRef(setPendingApprovals)
|
|
1028
|
-
setPendingApprovalsRef.current = setPendingApprovals
|
|
1029
|
-
|
|
1030
|
-
const adapter = useMemo(
|
|
1031
|
-
() =>
|
|
1032
|
-
createPikkuNonStreamingAdapter(
|
|
1033
|
-
optionsRef,
|
|
1034
|
-
pendingApprovalsRef,
|
|
1035
|
-
approvalDecisionsRef,
|
|
1036
|
-
setPendingApprovalsRef,
|
|
1037
|
-
onFinishRef
|
|
1038
|
-
),
|
|
352
|
+
const agent = useMemo(
|
|
353
|
+
() => new PikkuAgent(options),
|
|
354
|
+
// agent is intentionally created once; opts are synced via updateOpts
|
|
355
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1039
356
|
[]
|
|
1040
357
|
)
|
|
1041
358
|
|
|
1042
|
-
|
|
1043
|
-
()
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
agent.updateOpts(options)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
const { unsubscribe } = agent.subscribe({
|
|
365
|
+
onCustomEvent: ({ event }) => {
|
|
366
|
+
const e = event as AgUiCustomEvent
|
|
367
|
+
if (e.name === 'pikku:approval-request') {
|
|
368
|
+
const v = e.value as any
|
|
369
|
+
commitPending([
|
|
370
|
+
...pendingApprovalsRef.current,
|
|
371
|
+
{
|
|
372
|
+
toolCallId: v.toolCallId,
|
|
373
|
+
toolName: v.toolName,
|
|
374
|
+
args: v.args,
|
|
375
|
+
reason: v.reason,
|
|
376
|
+
runId: v.runId,
|
|
377
|
+
type: 'approval-request' as const,
|
|
378
|
+
},
|
|
379
|
+
])
|
|
380
|
+
} else if (e.name === 'pikku:credential-request') {
|
|
381
|
+
const v = e.value as any
|
|
382
|
+
commitPending([
|
|
383
|
+
...pendingApprovalsRef.current,
|
|
384
|
+
{
|
|
385
|
+
toolCallId: v.toolCallId,
|
|
386
|
+
toolName: v.toolName,
|
|
387
|
+
args: v.args,
|
|
388
|
+
runId: v.runId,
|
|
389
|
+
type: 'credential-request' as const,
|
|
390
|
+
credentialName: v.credentialName,
|
|
391
|
+
credentialType: v.credentialType,
|
|
392
|
+
connectUrl: v.connectUrl,
|
|
393
|
+
},
|
|
394
|
+
])
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
onRunFinalized: () => {
|
|
398
|
+
onFinishRef.current?.()
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
return unsubscribe
|
|
402
|
+
}, [agent, commitPending])
|
|
403
|
+
|
|
404
|
+
// Hydrate the thread from prior messages via the AG-UI history adapter,
|
|
405
|
+
// which useAgUiRuntime loads once on mount. Built once — see the
|
|
406
|
+
// initialMessages doc note about keeping the chat unmounted until ready.
|
|
407
|
+
const history = useMemo<ThreadHistoryAdapter | undefined>(() => {
|
|
408
|
+
if (!options.initialMessages?.length) return undefined
|
|
409
|
+
const repository = ExportedMessageRepository.fromArray(
|
|
1044
410
|
options.initialMessages
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
411
|
+
)
|
|
412
|
+
return {
|
|
413
|
+
load: async () => repository,
|
|
414
|
+
append: async () => {},
|
|
415
|
+
}
|
|
416
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
417
|
+
}, [])
|
|
418
|
+
|
|
419
|
+
const runtime = useAgUiRuntime(
|
|
420
|
+
history ? { agent, adapters: { history } } : { agent }
|
|
1048
421
|
)
|
|
1049
422
|
|
|
1050
|
-
const
|
|
423
|
+
const optionsRef = useRef(options)
|
|
424
|
+
optionsRef.current = options
|
|
425
|
+
const resumeChainRef = useRef<Promise<void>>(Promise.resolve())
|
|
1051
426
|
|
|
427
|
+
// The runtime only aggregates events from runs it starts itself, so the
|
|
428
|
+
// final approval is queued on the agent and triggered by the caller's
|
|
429
|
+
// addResult (which makes the runtime start the resume run once every tool
|
|
430
|
+
// call has a result). Earlier approvals in a batch are acknowledged with
|
|
431
|
+
// plain requests — their stream carries no content beyond 'done'.
|
|
1052
432
|
const handleApproval = useCallback(
|
|
1053
|
-
(toolCallId: string, approved: boolean) => {
|
|
1054
|
-
|
|
433
|
+
async (toolCallId: string, approved: boolean): Promise<boolean> => {
|
|
434
|
+
const approval = pendingApprovalsRef.current.find(
|
|
435
|
+
(p) => p.toolCallId === toolCallId
|
|
436
|
+
)
|
|
437
|
+
if (!approval || !approval.runId) return false
|
|
438
|
+
const remaining = pendingApprovalsRef.current.filter(
|
|
439
|
+
(p) => p.toolCallId !== toolCallId
|
|
440
|
+
)
|
|
441
|
+
commitPending(remaining)
|
|
442
|
+
const resume = { runId: approval.runId, toolCallId, approved }
|
|
443
|
+
|
|
444
|
+
if (remaining.length > 0) {
|
|
445
|
+
resumeChainRef.current = resumeChainRef.current.then(async () => {
|
|
446
|
+
const opts = optionsRef.current
|
|
447
|
+
try {
|
|
448
|
+
const response = await fetch(
|
|
449
|
+
`${opts.api}/${opts.agentName}/resume`,
|
|
450
|
+
{
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: {
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
Accept: 'text/event-stream',
|
|
455
|
+
...opts.headers,
|
|
456
|
+
},
|
|
457
|
+
credentials: opts.credentials,
|
|
458
|
+
body: JSON.stringify(resume),
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
await response.text()
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error('[pikku] failed to resolve approval', err)
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
await resumeChainRef.current
|
|
467
|
+
return true
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await resumeChainRef.current
|
|
471
|
+
agent.queueResume(resume)
|
|
472
|
+
return true
|
|
1055
473
|
},
|
|
1056
|
-
[]
|
|
474
|
+
[agent, commitPending]
|
|
1057
475
|
)
|
|
1058
476
|
|
|
1059
|
-
const isAwaitingApproval = pendingApprovals.length > 0
|
|
1060
|
-
|
|
1061
477
|
return {
|
|
1062
478
|
runtime,
|
|
1063
479
|
pendingApprovals,
|
|
1064
|
-
isAwaitingApproval,
|
|
480
|
+
isAwaitingApproval: pendingApprovals.length > 0,
|
|
1065
481
|
handleApproval,
|
|
1066
482
|
}
|
|
1067
483
|
}
|