@tetherto/mdk-ui-agent 0.0.0 → 0.12.1
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/LICENSE +202 -0
- package/README.md +310 -0
- package/dist/branding.d.ts +36 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +37 -0
- package/dist/branding.js.map +1 -0
- package/dist/components/approval-card.d.ts +46 -0
- package/dist/components/approval-card.d.ts.map +1 -0
- package/dist/components/approval-card.js +84 -0
- package/dist/components/approval-card.js.map +1 -0
- package/dist/components/assistant-message.d.ts +25 -0
- package/dist/components/assistant-message.d.ts.map +1 -0
- package/dist/components/assistant-message.js +49 -0
- package/dist/components/assistant-message.js.map +1 -0
- package/dist/components/co-pilot-panel.d.ts +17 -0
- package/dist/components/co-pilot-panel.d.ts.map +1 -0
- package/dist/components/co-pilot-panel.js +33 -0
- package/dist/components/co-pilot-panel.js.map +1 -0
- package/dist/components/code-block.d.ts +17 -0
- package/dist/components/code-block.d.ts.map +1 -0
- package/dist/components/code-block.js +40 -0
- package/dist/components/code-block.js.map +1 -0
- package/dist/components/collapsible-answer.d.ts +26 -0
- package/dist/components/collapsible-answer.d.ts.map +1 -0
- package/dist/components/collapsible-answer.js +30 -0
- package/dist/components/collapsible-answer.js.map +1 -0
- package/dist/components/composer.d.ts +31 -0
- package/dist/components/composer.d.ts.map +1 -0
- package/dist/components/composer.js +82 -0
- package/dist/components/composer.js.map +1 -0
- package/dist/components/conversation-list.d.ts +21 -0
- package/dist/components/conversation-list.d.ts.map +1 -0
- package/dist/components/conversation-list.js +42 -0
- package/dist/components/conversation-list.js.map +1 -0
- package/dist/components/icons.d.ts +88 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +112 -0
- package/dist/components/icons.js.map +1 -0
- package/dist/components/launcher-fab.d.ts +8 -0
- package/dist/components/launcher-fab.d.ts.map +1 -0
- package/dist/components/launcher-fab.js +6 -0
- package/dist/components/launcher-fab.js.map +1 -0
- package/dist/components/leaked-tool-call-notice.d.ts +18 -0
- package/dist/components/leaked-tool-call-notice.d.ts.map +1 -0
- package/dist/components/leaked-tool-call-notice.js +14 -0
- package/dist/components/leaked-tool-call-notice.js.map +1 -0
- package/dist/components/markdown-link.d.ts +17 -0
- package/dist/components/markdown-link.d.ts.map +1 -0
- package/dist/components/markdown-link.js +18 -0
- package/dist/components/markdown-link.js.map +1 -0
- package/dist/components/markdown-text.d.ts +26 -0
- package/dist/components/markdown-text.d.ts.map +1 -0
- package/dist/components/markdown-text.js +32 -0
- package/dist/components/markdown-text.js.map +1 -0
- package/dist/components/message-list.d.ts +24 -0
- package/dist/components/message-list.d.ts.map +1 -0
- package/dist/components/message-list.js +92 -0
- package/dist/components/message-list.js.map +1 -0
- package/dist/components/no-tools-note.d.ts +26 -0
- package/dist/components/no-tools-note.d.ts.map +1 -0
- package/dist/components/no-tools-note.js +27 -0
- package/dist/components/no-tools-note.js.map +1 -0
- package/dist/components/panel-header.d.ts +26 -0
- package/dist/components/panel-header.d.ts.map +1 -0
- package/dist/components/panel-header.js +13 -0
- package/dist/components/panel-header.js.map +1 -0
- package/dist/components/rehype-highlight-subset.d.ts +17 -0
- package/dist/components/rehype-highlight-subset.d.ts.map +1 -0
- package/dist/components/rehype-highlight-subset.js +77 -0
- package/dist/components/rehype-highlight-subset.js.map +1 -0
- package/dist/components/system-message.d.ts +15 -0
- package/dist/components/system-message.d.ts.map +1 -0
- package/dist/components/system-message.js +12 -0
- package/dist/components/system-message.js.map +1 -0
- package/dist/components/tool-chip.d.ts +19 -0
- package/dist/components/tool-chip.d.ts.map +1 -0
- package/dist/components/tool-chip.js +32 -0
- package/dist/components/tool-chip.js.map +1 -0
- package/dist/components/user-message.d.ts +9 -0
- package/dist/components/user-message.d.ts.map +1 -0
- package/dist/components/user-message.js +7 -0
- package/dist/components/user-message.js.map +1 -0
- package/dist/core/conversation-store.d.ts +107 -0
- package/dist/core/conversation-store.d.ts.map +1 -0
- package/dist/core/conversation-store.js +254 -0
- package/dist/core/conversation-store.js.map +1 -0
- package/dist/core/error-text.d.ts +19 -0
- package/dist/core/error-text.d.ts.map +1 -0
- package/dist/core/error-text.js +35 -0
- package/dist/core/error-text.js.map +1 -0
- package/dist/core/events.d.ts +98 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +50 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/format.d.ts +23 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +49 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/index.d.ts +27 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +20 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/markdown.d.ts +26 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +49 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/prose.d.ts +27 -0
- package/dist/core/prose.d.ts.map +1 -0
- package/dist/core/prose.js +79 -0
- package/dist/core/prose.js.map +1 -0
- package/dist/core/small-talk.d.ts +15 -0
- package/dist/core/small-talk.d.ts.map +1 -0
- package/dist/core/small-talk.js +42 -0
- package/dist/core/small-talk.js.map +1 -0
- package/dist/core/sse.d.ts +108 -0
- package/dist/core/sse.d.ts.map +1 -0
- package/dist/core/sse.js +236 -0
- package/dist/core/sse.js.map +1 -0
- package/dist/core/tool-label.d.ts +16 -0
- package/dist/core/tool-label.d.ts.map +1 -0
- package/dist/core/tool-label.js +31 -0
- package/dist/core/tool-label.js.map +1 -0
- package/dist/core/transport.d.ts +71 -0
- package/dist/core/transport.d.ts.map +1 -0
- package/dist/core/transport.js +147 -0
- package/dist/core/transport.js.map +1 -0
- package/dist/core/turn.d.ts +87 -0
- package/dist/core/turn.d.ts.map +1 -0
- package/dist/core/turn.js +232 -0
- package/dist/core/turn.js.map +1 -0
- package/dist/core/uuid.d.ts +21 -0
- package/dist/core/uuid.d.ts.map +1 -0
- package/dist/core/uuid.js +54 -0
- package/dist/core/uuid.js.map +1 -0
- package/dist/entries/chat-page.d.ts +28 -0
- package/dist/entries/chat-page.d.ts.map +1 -0
- package/dist/entries/chat-page.js +25 -0
- package/dist/entries/chat-page.js.map +1 -0
- package/dist/entries/co-pilot.d.ts +41 -0
- package/dist/entries/co-pilot.d.ts.map +1 -0
- package/dist/entries/co-pilot.js +39 -0
- package/dist/entries/co-pilot.js.map +1 -0
- package/dist/entries/lazy-panel.d.ts +15 -0
- package/dist/entries/lazy-panel.d.ts.map +1 -0
- package/dist/entries/lazy-panel.js +19 -0
- package/dist/entries/lazy-panel.js.map +1 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-agent-chat.d.ts +46 -0
- package/dist/hooks/use-agent-chat.d.ts.map +1 -0
- package/dist/hooks/use-agent-chat.js +288 -0
- package/dist/hooks/use-agent-chat.js.map +1 -0
- package/dist/hooks/use-agent-config.d.ts +29 -0
- package/dist/hooks/use-agent-config.d.ts.map +1 -0
- package/dist/hooks/use-agent-config.js +45 -0
- package/dist/hooks/use-agent-config.js.map +1 -0
- package/dist/hooks/use-conversations.d.ts +19 -0
- package/dist/hooks/use-conversations.d.ts.map +1 -0
- package/dist/hooks/use-conversations.js +19 -0
- package/dist/hooks/use-conversations.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +1 -0
- package/package.json +105 -1
- package/src/branding.ts +40 -0
- package/src/components/approval-card.tsx +141 -0
- package/src/components/assistant-message.tsx +140 -0
- package/src/components/co-pilot-panel.tsx +109 -0
- package/src/components/code-block.tsx +63 -0
- package/src/components/collapsible-answer.tsx +55 -0
- package/src/components/composer.tsx +147 -0
- package/src/components/conversation-list.tsx +141 -0
- package/src/components/icons.tsx +189 -0
- package/src/components/launcher-fab.tsx +16 -0
- package/src/components/leaked-tool-call-notice.tsx +29 -0
- package/src/components/markdown-link.tsx +30 -0
- package/src/components/markdown-text.tsx +58 -0
- package/src/components/message-list.tsx +164 -0
- package/src/components/no-tools-note.tsx +33 -0
- package/src/components/panel-header.tsx +86 -0
- package/src/components/rehype-highlight-subset.ts +84 -0
- package/src/components/system-message.tsx +19 -0
- package/src/components/tool-chip.tsx +58 -0
- package/src/components/user-message.tsx +18 -0
- package/src/core/conversation-store.ts +342 -0
- package/src/core/error-text.ts +36 -0
- package/src/core/events.ts +127 -0
- package/src/core/format.ts +59 -0
- package/src/core/index.ts +115 -0
- package/src/core/markdown.ts +52 -0
- package/src/core/prose.ts +78 -0
- package/src/core/small-talk.ts +45 -0
- package/src/core/sse.ts +269 -0
- package/src/core/tool-label.ts +33 -0
- package/src/core/transport.ts +208 -0
- package/src/core/turn.ts +299 -0
- package/src/core/uuid.ts +59 -0
- package/src/entries/chat-page.tsx +56 -0
- package/src/entries/co-pilot.tsx +84 -0
- package/src/entries/lazy-panel.tsx +19 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/use-agent-chat.ts +387 -0
- package/src/hooks/use-agent-config.ts +70 -0
- package/src/hooks/use-conversations.ts +39 -0
- package/src/index.ts +30 -0
- package/src/styles/_approval.scss +68 -0
- package/src/styles/_composer.scss +45 -0
- package/src/styles/_conversations.scss +160 -0
- package/src/styles/_messages.scss +444 -0
- package/src/styles/_mixins.scss +27 -0
- package/src/styles/_panel.scss +147 -0
- package/src/styles/_tokens.scss +30 -0
- package/src/styles.scss +16 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two text repairs applied before an answer is rendered as markdown.
|
|
3
|
+
*
|
|
4
|
+
* Both exist because the model decides its own output format and nothing in the
|
|
5
|
+
* charter constrains it: the panel has to read whatever comes back.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Opening fence of a whole-answer wrapper: ```markdown / ```md, nothing else on the line. */
|
|
9
|
+
const MARKDOWN_FENCE_OPEN = /^`{3,}\s*(?:markdown|md)\s*$/i
|
|
10
|
+
/** Any closing fence line. */
|
|
11
|
+
const FENCE_CLOSE = /^`{3,}\s*$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unwraps an answer the model wrapped entirely in a ```markdown fence.
|
|
15
|
+
*
|
|
16
|
+
* A common quirk: asked for a formatted answer, the model returns the formatted
|
|
17
|
+
* answer inside a fence, and the panel renders the whole reply as one code block
|
|
18
|
+
* — table syntax, bullets and all. Only the whole-answer case is unwrapped, and
|
|
19
|
+
* only when the fence actually closes at the end: an ordinary code fence, an
|
|
20
|
+
* unclosed fence mid-stream, and a fence with prose after it are all left alone.
|
|
21
|
+
*/
|
|
22
|
+
export function unwrapWholeAnswerFence(text: string): string {
|
|
23
|
+
const lines = text.trim().split('\n')
|
|
24
|
+
const first = lines[0]
|
|
25
|
+
if (lines.length < 2 || first === undefined || !MARKDOWN_FENCE_OPEN.test(first)) return text
|
|
26
|
+
|
|
27
|
+
// Paired with the last fence line, which is a whole-answer wrapper only if it is
|
|
28
|
+
// also the final line: an unclosed fence, or one with prose after it, is not.
|
|
29
|
+
const last = lines[lines.length - 1]
|
|
30
|
+
if (last === undefined || !FENCE_CLOSE.test(last)) return text
|
|
31
|
+
|
|
32
|
+
return lines.slice(1, -1).join('\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cuts `text` to roughly `max` characters, on the last paragraph or line break
|
|
37
|
+
* before the limit.
|
|
38
|
+
*
|
|
39
|
+
* Slicing markdown at an arbitrary character can split a fence or a table row and
|
|
40
|
+
* make the preview render as something the answer never said, so the cut lands on
|
|
41
|
+
* a block boundary where there is one within reach.
|
|
42
|
+
*/
|
|
43
|
+
export function clipToBoundary(text: string, max: number): string {
|
|
44
|
+
if (text.length <= max) return text
|
|
45
|
+
|
|
46
|
+
const head = text.slice(0, max)
|
|
47
|
+
const paragraph = head.lastIndexOf('\n\n')
|
|
48
|
+
const line = head.lastIndexOf('\n')
|
|
49
|
+
const cut = paragraph > max / 2 ? paragraph : line > max / 2 ? line : max
|
|
50
|
+
|
|
51
|
+
return head.slice(0, cut).trimEnd()
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects a tool call that leaked into the answer.
|
|
3
|
+
*
|
|
4
|
+
* The agent loop is prompt-based, not native function calling: the model is told
|
|
5
|
+
* to reply with only `{"tool": "<name>", "args": { ... }}`, and
|
|
6
|
+
* `backend/core/agent/src/loop.js` decides prose-vs-tool-call from the first
|
|
7
|
+
* non-whitespace character being `{`. A model that wraps its JSON in a ```json
|
|
8
|
+
* fence therefore leads with a backtick, is classified as prose, and the raw
|
|
9
|
+
* tool call is streamed to the operator as if it were the answer. Measured at
|
|
10
|
+
* roughly one read in eight against `gemma3:4b` — common enough to handle.
|
|
11
|
+
*
|
|
12
|
+
* Rendering that verbatim is the single most confusing failure mode in the
|
|
13
|
+
* stack, so it is detected and labelled instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FENCE = /^```[a-z]*\s*|\n?```$/gi
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The first balanced JSON object in `text`, string-aware so a brace inside a
|
|
20
|
+
* string literal does not close it. Mirrors `extractJsonObject` in the producer.
|
|
21
|
+
*/
|
|
22
|
+
function extractJsonObject(text: string): string | null {
|
|
23
|
+
const start = text.indexOf('{')
|
|
24
|
+
if (start === -1) return null
|
|
25
|
+
|
|
26
|
+
let depth = 0
|
|
27
|
+
let inString = false
|
|
28
|
+
let escaped = false
|
|
29
|
+
|
|
30
|
+
for (let index = start; index < text.length; index += 1) {
|
|
31
|
+
const char = text[index]
|
|
32
|
+
if (inString) {
|
|
33
|
+
if (escaped) escaped = false
|
|
34
|
+
else if (char === '\\') escaped = true
|
|
35
|
+
else if (char === '"') inString = false
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (char === '"') {
|
|
39
|
+
inString = true
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (char === '{') depth += 1
|
|
43
|
+
else if (char === '}') {
|
|
44
|
+
depth -= 1
|
|
45
|
+
if (depth === 0) return text.slice(start, index + 1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type LeakedToolCall = { tool: string, args: Record<string, unknown> }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the leaked call when `text` is nothing but a tool call, else null.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately strict: the JSON must be the whole message once code fences are
|
|
57
|
+
* stripped. A genuine answer that merely quotes some JSON keeps rendering as
|
|
58
|
+
* prose.
|
|
59
|
+
*/
|
|
60
|
+
export function parseLeakedToolCall(text: string): LeakedToolCall | null {
|
|
61
|
+
const stripped = text.trim().replace(FENCE, '').trim()
|
|
62
|
+
if (!stripped.startsWith('{')) return null
|
|
63
|
+
|
|
64
|
+
const raw = extractJsonObject(stripped)
|
|
65
|
+
if (raw === null || raw !== stripped) return null
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(raw) as { tool?: unknown, args?: unknown }
|
|
69
|
+
if (typeof parsed.tool !== 'string') return null
|
|
70
|
+
const args
|
|
71
|
+
= typeof parsed.args === 'object' && parsed.args !== null
|
|
72
|
+
? (parsed.args as Record<string, unknown>)
|
|
73
|
+
: {}
|
|
74
|
+
return { tool: parsed.tool, args }
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Was an operator message conversation rather than a question about the fleet?
|
|
3
|
+
*
|
|
4
|
+
* This exists for one caller: whether an answer is worth marking as having used no tools. The
|
|
5
|
+
* marker is there to catch an answer that should have consulted the fleet and did not — and
|
|
6
|
+
* against a greeting, a thank-you, or "what can you do", calling nothing was the right move, so
|
|
7
|
+
* every one of those was carrying a warning about a failure that had not happened.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately asymmetric. All three gates below must agree before a message counts as small
|
|
10
|
+
* talk, and any fleet word vetoes it outright, because the two mistakes are not equal: missing a
|
|
11
|
+
* greeting costs one redundant marker, while reading a fleet question as small talk hides exactly
|
|
12
|
+
* the warning the marker exists to give.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Openers where the greeting *is* the message — nothing is being asked of the fleet. */
|
|
16
|
+
const CONVERSATIONAL = [
|
|
17
|
+
/^(?:hi|hey|hello|yo|hiya|howdy|greetings|good (?:morning|afternoon|evening|day))\b/,
|
|
18
|
+
/^(?:how are you|how's it going|what's up|sup)\b/,
|
|
19
|
+
/^(?:thanks|thank you|thx|ty|cheers|nice|great|cool|awesome|perfect|got it|ok|okay|sure|sorry|never mind|nvm)\b/,
|
|
20
|
+
/^(?:bye|goodbye|see you|see ya|later)\b/,
|
|
21
|
+
/^(?:who are you|what are you|what can you do|what do you do|can you help|help)\b/,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Words that make a message about the fleet however it opens, so "hey, list the miners" is not
|
|
26
|
+
* small talk. Kept to unambiguous terms: "up" and "down" are fleet state but also "what's up".
|
|
27
|
+
*/
|
|
28
|
+
const FLEET_TERMS
|
|
29
|
+
= /\b(?:device|devices|miner|miners|asic|asics|rig|rigs|worker|workers|site|container|containers|pool|pools|sensor|sensors|powermeter|powermeters|power meter|hashrate|hash rate|temperature|telemetry|fleet|rank|reboot|restart|offline|online|status|list|show|count)\b/
|
|
30
|
+
|
|
31
|
+
/** Past this, a message is doing more than saying hello, whatever it opened with. */
|
|
32
|
+
const MAX_WORDS = 6
|
|
33
|
+
|
|
34
|
+
export function isSmallTalk(text: string): boolean {
|
|
35
|
+
const normalized = text
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replaceAll('’', '\'') // a typographic apostrophe still spells "what's"
|
|
38
|
+
.trim()
|
|
39
|
+
|
|
40
|
+
if (normalized.length === 0) return false
|
|
41
|
+
if (FLEET_TERMS.test(normalized)) return false
|
|
42
|
+
if (normalized.split(/\s+/).length > MAX_WORDS) return false
|
|
43
|
+
|
|
44
|
+
return CONVERSATIONAL.some((pattern) => pattern.test(normalized))
|
|
45
|
+
}
|
package/src/core/sse.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE reading for the agent message route.
|
|
3
|
+
*
|
|
4
|
+
* `EventSource` is unusable here: the message is a POST body and `EventSource`
|
|
5
|
+
* is GET-only. So this is `fetch` + a body-stream reader, which is also what the
|
|
6
|
+
* producer's own docs prescribe.
|
|
7
|
+
*
|
|
8
|
+
* The gateway writes `event: <type>\ndata: <json>\n\n` and nothing else — no
|
|
9
|
+
* `id:`, no `retry:`, and no heartbeat comment frames. The `event:` line is
|
|
10
|
+
* redundant (`data.type` carries the same value) so only `data:` is parsed. The
|
|
11
|
+
* rest of the parser is nonetheless written against the spec rather than against
|
|
12
|
+
* that one producer: CRLF line endings and multi-line `data:` are both legal, and
|
|
13
|
+
* a proxy that rewrites line endings would otherwise silently end every turn as a
|
|
14
|
+
* truncation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { WireEvent } from './events'
|
|
18
|
+
import { EVENT, isTerminal, isWireEvent } from './events'
|
|
19
|
+
|
|
20
|
+
/** A blank line ends a frame; either line ending is legal (spec §7). */
|
|
21
|
+
const FRAME_SEPARATOR = /\r?\n\r?\n/
|
|
22
|
+
const LINE_SEPARATOR = /\r?\n/
|
|
23
|
+
const DATA_PREFIX = 'data:'
|
|
24
|
+
|
|
25
|
+
/** Synthesized locally, never sent by the gateway. See `readAgentStream`. */
|
|
26
|
+
export const ERR_STREAM_TRUNCATED = 'ERR_AGENT_STREAM_TRUNCATED'
|
|
27
|
+
/** Synthesized locally: the socket stayed open but nothing arrived. See `readAgentStream`. */
|
|
28
|
+
export const ERR_STREAM_IDLE = 'ERR_AGENT_STREAM_IDLE'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How long a turn may say nothing between events before the connection is
|
|
32
|
+
* assumed lost.
|
|
33
|
+
*
|
|
34
|
+
* The gateway sends no heartbeats, so silence is the only symptom of a wedged
|
|
35
|
+
* agent, a half-closed proxy or a laptop resumed from sleep. Once events are
|
|
36
|
+
* flowing, 30s of nothing is already far outside what a working turn does: the
|
|
37
|
+
* producer emits a frame per token.
|
|
38
|
+
*/
|
|
39
|
+
export const STREAM_IDLE_TIMEOUT_MS = 30_000
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same limit before the first event, which is a different wait entirely.
|
|
43
|
+
*
|
|
44
|
+
* Nothing is emitted until the producer's first model call returns a routing
|
|
45
|
+
* decision, and a local model may think for a long time before it answers a
|
|
46
|
+
* single character: measured against a 4B thinking model on this hardware, the
|
|
47
|
+
* first event arrived at 61s. Held to the producer's own per-call budget
|
|
48
|
+
* (`REQUEST_TIMEOUT_MS`, 120s in backend/core/agent/src/constants.js) so a turn
|
|
49
|
+
* the server is still legitimately working on is never abandoned here first —
|
|
50
|
+
* whatever the model does, the server gives up before this does and says why.
|
|
51
|
+
*/
|
|
52
|
+
export const FIRST_EVENT_IDLE_TIMEOUT_MS = 120_000
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The same limit while an approval is outstanding, where silence is legitimate:
|
|
56
|
+
* the producer is waiting on a human, and only auto-rejects after its own
|
|
57
|
+
* `agent.approvalTimeoutMs`. Five minutes is that default plus room to spare, so
|
|
58
|
+
* the stream is never given up on before the server itself would have resumed it.
|
|
59
|
+
*/
|
|
60
|
+
export const APPROVAL_IDLE_TIMEOUT_MS = 300_000
|
|
61
|
+
|
|
62
|
+
/** Thrown out of `readFrames` when the deadline expires; converted to an event above. */
|
|
63
|
+
export class StreamIdleError extends Error {
|
|
64
|
+
constructor() {
|
|
65
|
+
super(ERR_STREAM_IDLE)
|
|
66
|
+
this.name = 'StreamIdleError'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ReadFramesOptions = {
|
|
71
|
+
/**
|
|
72
|
+
* Milliseconds of silence tolerated before the next read is given up on, read
|
|
73
|
+
* fresh before every read so the caller can widen it mid-stream. `0` disables
|
|
74
|
+
* the deadline.
|
|
75
|
+
*/
|
|
76
|
+
idleTimeoutMs?: () => number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pulls the `data:` payload out of one frame.
|
|
81
|
+
*
|
|
82
|
+
* A frame may carry several `data:` lines, which the spec joins with newlines;
|
|
83
|
+
* anything else (`event:`, `id:`, a `:` comment) is ignored. Returns null for a
|
|
84
|
+
* frame with no data at all.
|
|
85
|
+
*/
|
|
86
|
+
export function parseFrame(frame: string): unknown {
|
|
87
|
+
const data = frame
|
|
88
|
+
.split(LINE_SEPARATOR)
|
|
89
|
+
.filter((line) => line.startsWith(DATA_PREFIX))
|
|
90
|
+
.map((line) => line.slice(DATA_PREFIX.length).trim())
|
|
91
|
+
|
|
92
|
+
if (data.length === 0) return null
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(data.join('\n'))
|
|
96
|
+
} catch {
|
|
97
|
+
// A frame we cannot parse is indistinguishable from an event type we do not
|
|
98
|
+
// know about, and invariant 5 says ignore those rather than throw.
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const IDLE = Symbol('idle')
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One `read()`, given up on after `timeoutMs` of silence.
|
|
107
|
+
*
|
|
108
|
+
* The abandoned read is left attached to a rejection handler on purpose: the
|
|
109
|
+
* caller's `finally` cancels the reader straight after, and an unobserved
|
|
110
|
+
* rejection from a read parked on a socket we just cancelled would surface as a
|
|
111
|
+
* global unhandled rejection.
|
|
112
|
+
*/
|
|
113
|
+
async function readWithDeadline(
|
|
114
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
): Promise<ReadableStreamReadResult<Uint8Array> | typeof IDLE> {
|
|
117
|
+
const read = reader.read()
|
|
118
|
+
if (timeoutMs <= 0) return read
|
|
119
|
+
read.catch(() => {})
|
|
120
|
+
|
|
121
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
122
|
+
try {
|
|
123
|
+
return await Promise.race([
|
|
124
|
+
read,
|
|
125
|
+
new Promise<typeof IDLE>((resolve) => {
|
|
126
|
+
timer = setTimeout(() => resolve(IDLE), timeoutMs)
|
|
127
|
+
}),
|
|
128
|
+
])
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Yields each decoded `data:` payload, in order, as frames arrive.
|
|
136
|
+
*
|
|
137
|
+
* The abort signal is handled here rather than left to `fetch`. Aborting a fetch
|
|
138
|
+
* whose headers have already arrived is only specified to error the body stream
|
|
139
|
+
* — cancelling the reader is what actually unblocks a `read()` that is parked
|
|
140
|
+
* waiting for the next token, which is where an abort almost always lands.
|
|
141
|
+
*/
|
|
142
|
+
export async function* readFrames(
|
|
143
|
+
stream: ReadableStream<Uint8Array>,
|
|
144
|
+
signal?: AbortSignal,
|
|
145
|
+
options: ReadFramesOptions = {},
|
|
146
|
+
): AsyncGenerator<unknown> {
|
|
147
|
+
const reader = stream.getReader()
|
|
148
|
+
const decoder = new TextDecoder()
|
|
149
|
+
let buffer = ''
|
|
150
|
+
|
|
151
|
+
const cancel = () => {
|
|
152
|
+
reader.cancel().catch(() => {
|
|
153
|
+
// Already closed or errored; there is nothing left to release.
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (signal?.aborted) cancel()
|
|
158
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
159
|
+
|
|
160
|
+
const drain = function* (): Generator<unknown> {
|
|
161
|
+
for (;;) {
|
|
162
|
+
const match = FRAME_SEPARATOR.exec(buffer)
|
|
163
|
+
if (!match) return
|
|
164
|
+
const frame = buffer.slice(0, match.index)
|
|
165
|
+
buffer = buffer.slice(match.index + match[0].length)
|
|
166
|
+
const payload = parseFrame(frame)
|
|
167
|
+
if (payload !== null) yield payload
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
for (;;) {
|
|
173
|
+
const outcome = await readWithDeadline(reader, options.idleTimeoutMs?.() ?? 0)
|
|
174
|
+
if (outcome === IDLE) throw new StreamIdleError()
|
|
175
|
+
|
|
176
|
+
const { value, done } = outcome
|
|
177
|
+
if (done) break
|
|
178
|
+
buffer += decoder.decode(value, { stream: true })
|
|
179
|
+
yield* drain()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Flush whatever the decoder was holding from an incomplete multibyte
|
|
183
|
+
// sequence, then the final frame if it arrived without its blank line.
|
|
184
|
+
buffer += decoder.decode()
|
|
185
|
+
yield* drain()
|
|
186
|
+
const tail = parseFrame(buffer)
|
|
187
|
+
if (tail !== null) yield tail
|
|
188
|
+
} finally {
|
|
189
|
+
signal?.removeEventListener('abort', cancel)
|
|
190
|
+
cancel()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type ReadAgentStreamOptions = {
|
|
195
|
+
/** Silence tolerated between events. Defaults to {@link STREAM_IDLE_TIMEOUT_MS}; `0` disables it. */
|
|
196
|
+
idleTimeoutMs?: number
|
|
197
|
+
/** Silence tolerated while an approval is outstanding. Defaults to {@link APPROVAL_IDLE_TIMEOUT_MS}. */
|
|
198
|
+
approvalIdleTimeoutMs?: number
|
|
199
|
+
/** Silence tolerated before the first event. Defaults to {@link FIRST_EVENT_IDLE_TIMEOUT_MS}. */
|
|
200
|
+
firstEventIdleTimeoutMs?: number
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Turns a live response into the guaranteed-well-formed event sequence the rest
|
|
205
|
+
* of the package is written against. Four contract obligations live here:
|
|
206
|
+
*
|
|
207
|
+
* 1. Unknown or malformed events are dropped, not thrown on (invariant 5).
|
|
208
|
+
* 2. Nothing is emitted after the terminal event (invariant 1), so a
|
|
209
|
+
* misbehaving producer cannot reopen a finished turn.
|
|
210
|
+
* 3. Every stream ends with exactly one terminal event. The gateway's stream
|
|
211
|
+
* route calls `rep.hijack()`, so a failure after the headers are sent just
|
|
212
|
+
* closes the socket — no `error` frame, no `done`. Left alone that reads as a
|
|
213
|
+
* turn that never finishes, so an EOF with no terminal event is converted
|
|
214
|
+
* into one here. An abort is excluded: the operator stopping the turn is not
|
|
215
|
+
* a failure, and nothing should be reported back to them as one.
|
|
216
|
+
* 4. A socket that stays open but stops delivering ends the same way. Without
|
|
217
|
+
* that, a consumer awaiting the next event parks forever and the panel stays
|
|
218
|
+
* busy until the page is reloaded. The deadline is per phase, because how much
|
|
219
|
+
* silence is normal is not constant across a turn: wide before the first event
|
|
220
|
+
* (the model is still thinking), wide again while an approval is outstanding
|
|
221
|
+
* (the producer is waiting on a human), narrow in between.
|
|
222
|
+
*/
|
|
223
|
+
export async function* readAgentStream(
|
|
224
|
+
stream: ReadableStream<Uint8Array>,
|
|
225
|
+
signal?: AbortSignal,
|
|
226
|
+
options: ReadAgentStreamOptions = {},
|
|
227
|
+
): AsyncGenerator<WireEvent> {
|
|
228
|
+
const streamingTimeout = options.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS
|
|
229
|
+
const approvalTimeout = options.approvalIdleTimeoutMs ?? APPROVAL_IDLE_TIMEOUT_MS
|
|
230
|
+
const firstEventTimeout = options.firstEventIdleTimeoutMs ?? FIRST_EVENT_IDLE_TIMEOUT_MS
|
|
231
|
+
|
|
232
|
+
let terminalSeen = false
|
|
233
|
+
let awaitingApproval = false
|
|
234
|
+
let seenAnyEvent = false
|
|
235
|
+
let idle = false
|
|
236
|
+
|
|
237
|
+
const idleTimeoutMs = () => {
|
|
238
|
+
if (streamingTimeout <= 0) return 0
|
|
239
|
+
if (awaitingApproval) return approvalTimeout
|
|
240
|
+
if (!seenAnyEvent) return firstEventTimeout
|
|
241
|
+
return streamingTimeout
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
for await (const payload of readFrames(stream, signal, { idleTimeoutMs })) {
|
|
246
|
+
if (!isWireEvent(payload)) continue
|
|
247
|
+
if (terminalSeen) continue
|
|
248
|
+
seenAnyEvent = true
|
|
249
|
+
// An approval pauses the producer; its result is what releases it.
|
|
250
|
+
if (payload.type === EVENT.PENDING_APPROVAL) awaitingApproval = true
|
|
251
|
+
if (payload.type === EVENT.TOOL_RESULT) awaitingApproval = false
|
|
252
|
+
if (isTerminal(payload)) terminalSeen = true
|
|
253
|
+
yield payload
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (!(error instanceof StreamIdleError)) throw error
|
|
257
|
+
idle = true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!terminalSeen && !signal?.aborted) {
|
|
261
|
+
yield {
|
|
262
|
+
type: EVENT.ERROR,
|
|
263
|
+
error: idle ? ERR_STREAM_IDLE : ERR_STREAM_TRUNCATED,
|
|
264
|
+
turnId: '',
|
|
265
|
+
seq: Number.MAX_SAFE_INTEGER,
|
|
266
|
+
ts: 0,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human labels for tool chips.
|
|
3
|
+
*
|
|
4
|
+
* Tools are supplied by whichever MCP server the operator points the agent at,
|
|
5
|
+
* so no name can be hardcoded here — `summarize_site` and `get_device` happen to
|
|
6
|
+
* come from the reference site tools, but another deployment ships another set
|
|
7
|
+
* entirely. Callers pass a `toolLabels` map for the names they know; everything
|
|
8
|
+
* else falls back to a readable form of the identifier.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type ToolLabels = Record<string, string>
|
|
12
|
+
|
|
13
|
+
/** `summarize_site` -> `Summarize site`. */
|
|
14
|
+
export function humanizeToolName(name: string): string {
|
|
15
|
+
const words = name
|
|
16
|
+
.replaceAll(/[_-]+/g, ' ')
|
|
17
|
+
.replaceAll(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
18
|
+
.trim()
|
|
19
|
+
if (words.length === 0) return name
|
|
20
|
+
return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toolLabel(name: string, labels?: ToolLabels): string {
|
|
24
|
+
return labels?.[name] ?? humanizeToolName(name)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `420` -> `0.4s`, so a chip reads `Site status · 0.4s`. */
|
|
28
|
+
export function formatDuration(durationMs: number | undefined): string | null {
|
|
29
|
+
if (durationMs === undefined || !Number.isFinite(durationMs)) return null
|
|
30
|
+
const seconds = durationMs / 1000
|
|
31
|
+
// Tenths stay readable up to a minute; past that the decimal is noise.
|
|
32
|
+
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`
|
|
33
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four agent routes, as declared in `backend/apps/agent/mdk-plugin.json`.
|
|
3
|
+
*
|
|
4
|
+
* POST /agent/sessions -> { sessionId }
|
|
5
|
+
* POST /agent/sessions/:id/messages -> text/event-stream
|
|
6
|
+
* POST /agent/sessions/:id/approvals/:approvalId -> { approvalId, approved }
|
|
7
|
+
* DELETE /agent/sessions/:id -> { sessionId, deleted }
|
|
8
|
+
*
|
|
9
|
+
* Note the approval route is session-scoped. `backend/core/agent/docs/CONTRACT.md`
|
|
10
|
+
* documents it as `/agent/turns/{turnId}/approvals/{approvalId}`; that is stale —
|
|
11
|
+
* `turnId` appears in no URL.
|
|
12
|
+
*
|
|
13
|
+
* The gateway sends no CORS headers, and the stream route calls `rep.hijack()` so
|
|
14
|
+
* no CORS hook could decorate it even if one existed. Callers must be same-origin
|
|
15
|
+
* (served from `staticRootPath`) or proxied (`/agent` in the dev server).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { WireEvent } from './events'
|
|
19
|
+
import type { ReadAgentStreamOptions } from './sse'
|
|
20
|
+
import { readAgentStream } from './sse'
|
|
21
|
+
|
|
22
|
+
export const AGENT_ROUTE_PREFIX = '/agent'
|
|
23
|
+
|
|
24
|
+
/** The `ERR_*` codes these routes can return. */
|
|
25
|
+
export const AGENT_ERROR = {
|
|
26
|
+
SESSION_NOT_FOUND: 'ERR_AGENT_SESSION_NOT_FOUND',
|
|
27
|
+
TURN_ACTIVE: 'ERR_AGENT_TURN_ACTIVE',
|
|
28
|
+
TEXT_REQUIRED: 'ERR_AGENT_MESSAGE_TEXT_REQUIRED',
|
|
29
|
+
UNAVAILABLE: 'ERR_AGENT_UNAVAILABLE',
|
|
30
|
+
APPROVAL_NOT_FOUND: 'ERR_AGENT_APPROVAL_NOT_FOUND',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export type AgentErrorCode = (typeof AGENT_ERROR)[keyof typeof AGENT_ERROR]
|
|
34
|
+
|
|
35
|
+
export type TokenSource = () => string | null | undefined | Promise<string | null | undefined>
|
|
36
|
+
|
|
37
|
+
export type AgentTransportOptions = {
|
|
38
|
+
/** Gateway origin. Empty string means relative URLs — i.e. same-origin or proxied. */
|
|
39
|
+
baseUrl?: string
|
|
40
|
+
getToken?: TokenSource
|
|
41
|
+
/** Injected in tests; defaults to the global `fetch`. */
|
|
42
|
+
fetchImpl?: typeof fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A gateway error carrying the `ERR_*` code the UI branches on. */
|
|
46
|
+
export class AgentApiError extends Error {
|
|
47
|
+
readonly status: number
|
|
48
|
+
readonly code: string
|
|
49
|
+
|
|
50
|
+
constructor(status: number, code: string, message?: string) {
|
|
51
|
+
super(message ?? code)
|
|
52
|
+
this.name = 'AgentApiError'
|
|
53
|
+
this.status = status
|
|
54
|
+
this.code = code
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
is(code: AgentErrorCode): boolean {
|
|
58
|
+
return this.code === code
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Trimmed with a scan rather than /\/+$/: the regex backtracks quadratically on a long run
|
|
63
|
+
// of trailing slashes, which CodeQL flags as a polynomial ReDoS (js/polynomial-redos).
|
|
64
|
+
function stripTrailingSlashes(value: string): string {
|
|
65
|
+
let end = value.length
|
|
66
|
+
while (end > 0 && value[end - 1] === '/') end--
|
|
67
|
+
return value.slice(0, end)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function joinUrl(baseUrl: string | undefined, path: string): string {
|
|
71
|
+
if (!baseUrl) return path
|
|
72
|
+
return `${stripTrailingSlashes(baseUrl)}${path}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function buildHeaders(
|
|
76
|
+
options: AgentTransportOptions,
|
|
77
|
+
extra?: Record<string, string>,
|
|
78
|
+
): Promise<Headers> {
|
|
79
|
+
const headers = new Headers(extra)
|
|
80
|
+
const token = await options.getToken?.()
|
|
81
|
+
if (token) headers.set('authorization', `Bearer ${token}`)
|
|
82
|
+
return headers
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Both error body shapes, reduced to a code.
|
|
87
|
+
*
|
|
88
|
+
* Normal routes answer `{ statusCode, error, message }`; the streaming route
|
|
89
|
+
* answers `{ statusCode, message }` with no `error` key at all. Either way the
|
|
90
|
+
* `ERR_*` code is in `message`. `ERR_AGENT_UNAVAILABLE` additionally has its
|
|
91
|
+
* reason concatenated in (`"ERR_AGENT_UNAVAILABLE: config.agent missing"`), so
|
|
92
|
+
* the code is only the part before the colon.
|
|
93
|
+
*/
|
|
94
|
+
async function toApiError(response: Response): Promise<AgentApiError> {
|
|
95
|
+
let message = response.statusText
|
|
96
|
+
try {
|
|
97
|
+
const body = (await response.json()) as { message?: string }
|
|
98
|
+
if (typeof body?.message === 'string') message = body.message
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-JSON body (or none). The status alone still identifies the failure.
|
|
101
|
+
}
|
|
102
|
+
const code = message.split(':')[0]?.trim() || `HTTP_${response.status}`
|
|
103
|
+
return new AgentApiError(response.status, code, message)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function requestJson<T>(
|
|
107
|
+
path: string,
|
|
108
|
+
init: RequestInit,
|
|
109
|
+
options: AgentTransportOptions,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
112
|
+
const response = await fetchImpl(joinUrl(options.baseUrl, path), init)
|
|
113
|
+
if (!response.ok) throw await toApiError(response)
|
|
114
|
+
return (await response.json()) as T
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Identity comes from the token, so the request body is ignored by the gateway. */
|
|
118
|
+
export async function createSession(options: AgentTransportOptions = {}): Promise<string> {
|
|
119
|
+
const headers = await buildHeaders(options, { 'content-type': 'application/json' })
|
|
120
|
+
const body = await requestJson<{ sessionId: string }>(
|
|
121
|
+
`${AGENT_ROUTE_PREFIX}/sessions`,
|
|
122
|
+
{ method: 'POST', headers, body: '{}' },
|
|
123
|
+
options,
|
|
124
|
+
)
|
|
125
|
+
return body.sessionId
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function deleteSession(
|
|
129
|
+
sessionId: string,
|
|
130
|
+
options: AgentTransportOptions = {},
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const headers = await buildHeaders(options)
|
|
133
|
+
await requestJson(
|
|
134
|
+
`${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}`,
|
|
135
|
+
{ method: 'DELETE', headers },
|
|
136
|
+
options,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Answers a paused approval. The decision travels on its own request while the
|
|
142
|
+
* message stream stays open — the stream resumes on its own once this lands.
|
|
143
|
+
*
|
|
144
|
+
* The gateway tests `approved === true`, so anything else is a rejection. It is
|
|
145
|
+
* passed through as a real boolean rather than coerced, to keep that explicit.
|
|
146
|
+
*
|
|
147
|
+
* A decision that arrives after the approval timed out, or a second decision for
|
|
148
|
+
* the same approval, is a 404 `ERR_AGENT_APPROVAL_NOT_FOUND` — the two are
|
|
149
|
+
* deliberately indistinguishable. Callers should treat it as "already settled",
|
|
150
|
+
* not as a failure.
|
|
151
|
+
*/
|
|
152
|
+
export async function decideApproval(
|
|
153
|
+
sessionId: string,
|
|
154
|
+
approvalId: string,
|
|
155
|
+
approved: boolean,
|
|
156
|
+
options: AgentTransportOptions = {},
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const headers = await buildHeaders(options, { 'content-type': 'application/json' })
|
|
159
|
+
await requestJson(
|
|
160
|
+
`${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}`
|
|
161
|
+
+ `/approvals/${encodeURIComponent(approvalId)}`,
|
|
162
|
+
{ method: 'POST', headers, body: JSON.stringify({ approved }) },
|
|
163
|
+
options,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type StreamTurnOptions = AgentTransportOptions & ReadAgentStreamOptions & {
|
|
168
|
+
signal?: AbortSignal
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sends one message and yields its events until the turn ends.
|
|
173
|
+
*
|
|
174
|
+
* Only one turn may be in flight per session; a second send while this is
|
|
175
|
+
* running — including while it is paused on an approval, since a paused turn
|
|
176
|
+
* still holds the session busy — throws `ERR_AGENT_TURN_ACTIVE`.
|
|
177
|
+
*/
|
|
178
|
+
export async function* streamTurn(
|
|
179
|
+
sessionId: string,
|
|
180
|
+
text: string,
|
|
181
|
+
options: StreamTurnOptions = {},
|
|
182
|
+
): AsyncGenerator<WireEvent> {
|
|
183
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
184
|
+
const headers = await buildHeaders(options, {
|
|
185
|
+
'content-type': 'application/json',
|
|
186
|
+
'accept': 'text/event-stream',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const response = await fetchImpl(
|
|
190
|
+
joinUrl(options.baseUrl, `${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}/messages`),
|
|
191
|
+
{ method: 'POST', headers, body: JSON.stringify({ text }), signal: options.signal },
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// 404 / 409 / 400 are all raised before the SSE headers go out, so they arrive
|
|
195
|
+
// as an ordinary JSON body rather than as an event on the stream.
|
|
196
|
+
if (!response.ok) throw await toApiError(response)
|
|
197
|
+
if (!response.body) throw new AgentApiError(response.status, 'ERR_AGENT_STREAM_EMPTY')
|
|
198
|
+
|
|
199
|
+
yield* readAgentStream(response.body, options.signal, {
|
|
200
|
+
...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
|
|
201
|
+
...(options.approvalIdleTimeoutMs === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: { approvalIdleTimeoutMs: options.approvalIdleTimeoutMs }),
|
|
204
|
+
...(options.firstEventIdleTimeoutMs === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { firstEventIdleTimeoutMs: options.firstEventIdleTimeoutMs }),
|
|
207
|
+
})
|
|
208
|
+
}
|