@open-mercato/ai-assistant 0.6.2-develop.3406.1.2b403f40da → 0.6.2-develop.3446.1.bd060c6017
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -1
- package/build.mjs +1 -0
- package/dist/frontend/components/AiChatButton.js +1 -1
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +16 -5
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js +58 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/agents/route.js +2 -1
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +7 -1
- package/dist/modules/ai_assistant/i18n/en.json +7 -1
- package/dist/modules/ai_assistant/i18n/es.json +7 -1
- package/dist/modules/ai_assistant/i18n/pl.json +7 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +26 -6
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +21 -8
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/pending-action-types.js.map +2 -2
- package/dist/modules/ai_assistant/lib/prepare-mutation.js +16 -6
- package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +2 -2
- package/dist/modules/ai_assistant/lib/task-plan-labels.js +95 -0
- package/dist/modules/ai_assistant/lib/task-plan-labels.js.map +7 -0
- package/dist/modules/ai_assistant/lib/task-plan-stream.js +349 -0
- package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js +3 -0
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js.map +2 -2
- package/package.json +6 -6
- package/src/frontend/components/AiChatButton.tsx +1 -1
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +20 -8
- package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +60 -4
- package/src/modules/ai_assistant/ai-tools/meta-pack.ts +79 -2
- package/src/modules/ai_assistant/api/ai/agents/route.ts +2 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +1 -0
- package/src/modules/ai_assistant/i18n/de.json +7 -1
- package/src/modules/ai_assistant/i18n/en.json +7 -1
- package/src/modules/ai_assistant/i18n/es.json +7 -1
- package/src/modules/ai_assistant/i18n/pl.json +7 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-registry.test.ts +60 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-agent-definition.test.ts +4 -0
- package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +43 -0
- package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +375 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +36 -5
- package/src/modules/ai_assistant/lib/agent-runtime.ts +26 -8
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +14 -0
- package/src/modules/ai_assistant/lib/pending-action-types.ts +4 -1
- package/src/modules/ai_assistant/lib/prepare-mutation.ts +17 -5
- package/src/modules/ai_assistant/lib/task-plan-labels.ts +112 -0
- package/src/modules/ai_assistant/lib/task-plan-stream.ts +463 -0
- package/src/modules/ai_assistant/lib/tool-test-fixtures.ts +3 -0
- package/src/modules/ai_assistant/lib/types.ts +16 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe, user-visible task-plan labels for AI chat.
|
|
3
|
+
*
|
|
4
|
+
* These helpers intentionally reject text that looks like private reasoning.
|
|
5
|
+
* Task plans are UI copy for operators, not a channel for model scratchpads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const TASK_PLAN_TOOL_NAME = 'meta.update_task_plan'
|
|
9
|
+
export const TASK_PLAN_TOOL_NAME_SDK = 'meta__update_task_plan'
|
|
10
|
+
export const TASK_PLAN_MAX_TASKS = 8
|
|
11
|
+
export const TASK_PLAN_LABEL_MAX_CHARS = 80
|
|
12
|
+
export const TASK_PLAN_DETAIL_MAX_CHARS = 160
|
|
13
|
+
export const TASK_PLAN_ID_MAX_CHARS = 80
|
|
14
|
+
export const TASK_PLAN_TOOL_NAME_MAX_CHARS = 160
|
|
15
|
+
|
|
16
|
+
export interface SanitizedAgentTaskPlanInputTask {
|
|
17
|
+
id?: string
|
|
18
|
+
label: string
|
|
19
|
+
detail?: string
|
|
20
|
+
toolName?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SanitizedAgentTaskPlanInput {
|
|
24
|
+
tasks: SanitizedAgentTaskPlanInputTask[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const TASK_PLAN_RUNTIME_PROMPT_SECTION = [
|
|
28
|
+
'TASK PLAN (RUNTIME)',
|
|
29
|
+
'For every tool-using turn, first call `meta.update_task_plan` with 2-5 concise user-visible steps. Then call the domain/search/attachment/mutation tools.',
|
|
30
|
+
'Task labels are visible progress UI. Never include hidden reasoning, chain-of-thought, scratchpad notes, or XML thinking tags.',
|
|
31
|
+
'When a planned step maps to a known tool, include `toolName` so the chat can advance that row from pending to running to done.',
|
|
32
|
+
'Skip `meta.update_task_plan` for pure capability, example-question, or how-can-you-help prompts where no data tool is needed.',
|
|
33
|
+
].join('\n')
|
|
34
|
+
|
|
35
|
+
const HIDDEN_REASONING_PATTERNS: RegExp[] = [
|
|
36
|
+
/\bchain[-\s]?of[-\s]?thought\b/i,
|
|
37
|
+
/\binternal\s+(?:reasoning|thoughts?)\b/i,
|
|
38
|
+
/\bprivate\s+(?:reasoning|thoughts?)\b/i,
|
|
39
|
+
/\bhidden\s+(?:reasoning|thoughts?)\b/i,
|
|
40
|
+
/\bscratch\s*pad\b/i,
|
|
41
|
+
/\bscratchpad\b/i,
|
|
42
|
+
/\b(?:my\s+)?reasoning\s*:/i,
|
|
43
|
+
/<\/?\s*(?:thinking|thought|reasoning|scratchpad)\b/i,
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g
|
|
47
|
+
const WHITESPACE = /\s+/g
|
|
48
|
+
|
|
49
|
+
export function normalizeTaskPlanToolName(toolName: unknown): string | undefined {
|
|
50
|
+
if (typeof toolName !== 'string') return undefined
|
|
51
|
+
const trimmed = toolName.trim()
|
|
52
|
+
if (!trimmed) return undefined
|
|
53
|
+
const dotted = trimmed.replace(/__/g, '.')
|
|
54
|
+
const safe = dotted.replace(/[^a-zA-Z0-9._:-]/g, '').slice(0, TASK_PLAN_TOOL_NAME_MAX_CHARS)
|
|
55
|
+
return safe.length > 0 ? safe : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isTaskPlanToolName(toolName: unknown): boolean {
|
|
59
|
+
return normalizeTaskPlanToolName(toolName) === TASK_PLAN_TOOL_NAME
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function looksLikeHiddenReasoning(value: string): boolean {
|
|
63
|
+
return HIDDEN_REASONING_PATTERNS.some((pattern) => pattern.test(value))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sanitizeTaskPlanText(
|
|
67
|
+
value: unknown,
|
|
68
|
+
maxChars: number,
|
|
69
|
+
): string | null {
|
|
70
|
+
if (typeof value !== 'string') return null
|
|
71
|
+
const normalized = value.replace(CONTROL_CHARS, ' ').replace(WHITESPACE, ' ').trim()
|
|
72
|
+
if (!normalized) return null
|
|
73
|
+
if (looksLikeHiddenReasoning(normalized)) return null
|
|
74
|
+
return normalized.slice(0, maxChars)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sanitizeTaskPlanId(value: unknown): string | undefined {
|
|
78
|
+
if (typeof value !== 'string') return undefined
|
|
79
|
+
const normalized = value
|
|
80
|
+
.trim()
|
|
81
|
+
.replace(/\s+/g, '-')
|
|
82
|
+
.replace(/[^a-zA-Z0-9._:-]/g, '')
|
|
83
|
+
.slice(0, TASK_PLAN_ID_MAX_CHARS)
|
|
84
|
+
return normalized.length > 0 ? normalized : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sanitizeAgentTaskPlanInput(input: unknown): SanitizedAgentTaskPlanInput {
|
|
88
|
+
if (!input || typeof input !== 'object') {
|
|
89
|
+
return { tasks: [] }
|
|
90
|
+
}
|
|
91
|
+
const rawTasks = (input as { tasks?: unknown }).tasks
|
|
92
|
+
if (!Array.isArray(rawTasks)) {
|
|
93
|
+
return { tasks: [] }
|
|
94
|
+
}
|
|
95
|
+
const tasks: SanitizedAgentTaskPlanInputTask[] = []
|
|
96
|
+
for (const rawTask of rawTasks.slice(0, TASK_PLAN_MAX_TASKS)) {
|
|
97
|
+
if (!rawTask || typeof rawTask !== 'object') continue
|
|
98
|
+
const value = rawTask as Record<string, unknown>
|
|
99
|
+
const label = sanitizeTaskPlanText(value.label, TASK_PLAN_LABEL_MAX_CHARS)
|
|
100
|
+
if (!label) continue
|
|
101
|
+
const detail = sanitizeTaskPlanText(value.detail, TASK_PLAN_DETAIL_MAX_CHARS) ?? undefined
|
|
102
|
+
const id = sanitizeTaskPlanId(value.id)
|
|
103
|
+
const toolName = normalizeTaskPlanToolName(value.toolName)
|
|
104
|
+
tasks.push({
|
|
105
|
+
...(id ? { id } : {}),
|
|
106
|
+
label,
|
|
107
|
+
...(detail ? { detail } : {}),
|
|
108
|
+
...(toolName ? { toolName } : {}),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return { tasks }
|
|
112
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visible AI chat agent task plan — server-side SSE injector.
|
|
3
|
+
*
|
|
4
|
+
* Spec: `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`.
|
|
5
|
+
*
|
|
6
|
+
* Wraps a streaming `Response` produced by `streamText().toUIMessageStreamResponse()`
|
|
7
|
+
* (or the equivalent `ToolLoopAgent.stream(...).toUIMessageStreamResponse()`)
|
|
8
|
+
* and interleaves additive `data-agent-task-plan` / `data-agent-task-update`
|
|
9
|
+
* SSE chunks alongside the AI SDK tool lifecycle chunks. The original chunks
|
|
10
|
+
* are passed through unchanged so existing clients that ignore unknown chunk
|
|
11
|
+
* types continue to work.
|
|
12
|
+
*
|
|
13
|
+
* The injector derives task labels and states from the SDK tool lifecycle:
|
|
14
|
+
* - `tool-input-start` → create/update task with state `running`
|
|
15
|
+
* - `tool-input-available` → keep task `running` (label may be refined)
|
|
16
|
+
* - `tool-output-available` → mark task `done`
|
|
17
|
+
* - `tool-output-error` → mark task `failed`
|
|
18
|
+
* - `tool-input-error` → mark task `failed`
|
|
19
|
+
*
|
|
20
|
+
* Agent-authored labels flow through the reserved non-mutation
|
|
21
|
+
* `meta.update_task_plan` tool. Its input is sanitized before the plan reaches
|
|
22
|
+
* the client; the raw meta-tool call is still passed through for older clients
|
|
23
|
+
* but the visible plan uses only the safe labels.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
TASK_PLAN_LABEL_MAX_CHARS,
|
|
28
|
+
isTaskPlanToolName,
|
|
29
|
+
normalizeTaskPlanToolName,
|
|
30
|
+
sanitizeAgentTaskPlanInput,
|
|
31
|
+
} from './task-plan-labels'
|
|
32
|
+
|
|
33
|
+
const SSE_ENCODER = new TextEncoder()
|
|
34
|
+
const SSE_DECODER = new TextDecoder()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mirrors the client-side `AiAgentTaskSnapshot` so server and client agree on
|
|
38
|
+
* the wire format. Kept locally in this module to avoid pulling a UI package
|
|
39
|
+
* dependency into the runtime — the shape is small and only ever serialized
|
|
40
|
+
* to JSON for the SSE chunks below.
|
|
41
|
+
*/
|
|
42
|
+
export interface ServerTaskSnapshot {
|
|
43
|
+
id: string
|
|
44
|
+
label: string
|
|
45
|
+
state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
|
46
|
+
detail?: string
|
|
47
|
+
source: 'runtime' | 'agent'
|
|
48
|
+
toolCallId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TERMINAL_STATES: ReadonlySet<ServerTaskSnapshot['state']> = new Set([
|
|
52
|
+
'done',
|
|
53
|
+
'failed',
|
|
54
|
+
'skipped',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
const TASK_LABEL_MAX_CHARS = TASK_PLAN_LABEL_MAX_CHARS
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a raw model-sanitized tool name (e.g. `customers__list_people`) to a
|
|
61
|
+
* compact operator-facing label (e.g. `Customers list people`). The trailing
|
|
62
|
+
* segment is title-cased so the plan reads like a checklist instead of a code
|
|
63
|
+
* trace.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveTaskLabel(toolName: string | undefined): string {
|
|
66
|
+
if (typeof toolName !== 'string' || toolName.length === 0) {
|
|
67
|
+
return 'Tool call'
|
|
68
|
+
}
|
|
69
|
+
const display = toolName.replace(/__/g, '.')
|
|
70
|
+
const segments = display.split('.')
|
|
71
|
+
const lastSegment = segments[segments.length - 1] ?? display
|
|
72
|
+
const humanized = lastSegment.replace(/_/g, ' ').trim()
|
|
73
|
+
if (humanized.length === 0) return display.slice(0, TASK_LABEL_MAX_CHARS)
|
|
74
|
+
const titled = humanized.charAt(0).toUpperCase() + humanized.slice(1)
|
|
75
|
+
if (segments.length <= 1) {
|
|
76
|
+
return titled.slice(0, TASK_LABEL_MAX_CHARS)
|
|
77
|
+
}
|
|
78
|
+
const moduleSegment = segments[0]
|
|
79
|
+
const moduleLabel = moduleSegment.charAt(0).toUpperCase() + moduleSegment.slice(1).replace(/_/g, ' ')
|
|
80
|
+
const combined = `${moduleLabel} · ${titled}`
|
|
81
|
+
return combined.slice(0, TASK_LABEL_MAX_CHARS)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type AccumulatorEntry = {
|
|
85
|
+
snapshot: ServerTaskSnapshot
|
|
86
|
+
emitted: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type ToolChunk = {
|
|
90
|
+
type?: unknown
|
|
91
|
+
toolCallId?: unknown
|
|
92
|
+
toolName?: unknown
|
|
93
|
+
input?: unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Encapsulates the per-turn task-plan state. Exposed for unit tests so the
|
|
98
|
+
* derivation logic can be exercised without standing up a full SSE pipeline.
|
|
99
|
+
*/
|
|
100
|
+
export class TaskPlanAccumulator {
|
|
101
|
+
private readonly tasks = new Map<string, AccumulatorEntry>()
|
|
102
|
+
private readonly toolCallToTaskId = new Map<string, string>()
|
|
103
|
+
private readonly taskToolNames = new Map<string, string>()
|
|
104
|
+
private readonly internalToolCallIds = new Set<string>()
|
|
105
|
+
private snapshotEmitted = false
|
|
106
|
+
private hasAgentAuthoredPlan = false
|
|
107
|
+
|
|
108
|
+
constructor(public readonly planId: string) {}
|
|
109
|
+
|
|
110
|
+
private upsert(
|
|
111
|
+
id: string,
|
|
112
|
+
patch: Partial<ServerTaskSnapshot> & { label?: string; toolCallId?: string },
|
|
113
|
+
): ServerTaskSnapshot {
|
|
114
|
+
const existing = this.tasks.get(id)
|
|
115
|
+
if (!existing) {
|
|
116
|
+
const created: ServerTaskSnapshot = {
|
|
117
|
+
id,
|
|
118
|
+
label: patch.label ?? 'Tool call',
|
|
119
|
+
state: patch.state ?? 'running',
|
|
120
|
+
source: patch.source ?? 'runtime',
|
|
121
|
+
detail: patch.detail,
|
|
122
|
+
toolCallId: patch.toolCallId,
|
|
123
|
+
}
|
|
124
|
+
this.tasks.set(id, { snapshot: created, emitted: false })
|
|
125
|
+
return created
|
|
126
|
+
}
|
|
127
|
+
const current = existing.snapshot
|
|
128
|
+
const nextState = TERMINAL_STATES.has(current.state) ? current.state : patch.state ?? current.state
|
|
129
|
+
const merged: ServerTaskSnapshot = {
|
|
130
|
+
id: current.id,
|
|
131
|
+
label: patch.label ?? current.label,
|
|
132
|
+
state: nextState,
|
|
133
|
+
source: patch.source ?? current.source,
|
|
134
|
+
detail: patch.detail ?? current.detail,
|
|
135
|
+
toolCallId: patch.toolCallId ?? current.toolCallId,
|
|
136
|
+
}
|
|
137
|
+
this.tasks.set(id, { snapshot: merged, emitted: existing.emitted })
|
|
138
|
+
return merged
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private makeUniqueTaskId(baseId: string): string {
|
|
142
|
+
let candidate = baseId
|
|
143
|
+
let suffix = 2
|
|
144
|
+
while (this.tasks.has(candidate)) {
|
|
145
|
+
candidate = `${baseId}-${suffix}`
|
|
146
|
+
suffix += 1
|
|
147
|
+
}
|
|
148
|
+
return candidate
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private emitFullSnapshot(): string[] {
|
|
152
|
+
if (this.tasks.size === 0) return []
|
|
153
|
+
this.snapshotEmitted = true
|
|
154
|
+
const initialTasks = Array.from(this.tasks.values()).map((e) => e.snapshot)
|
|
155
|
+
for (const e of this.tasks.values()) e.emitted = true
|
|
156
|
+
return [
|
|
157
|
+
formatSseEvent({
|
|
158
|
+
type: 'data-agent-task-plan',
|
|
159
|
+
planId: this.planId,
|
|
160
|
+
tasks: initialTasks,
|
|
161
|
+
}),
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private handleAgentAuthoredPlan(input: unknown): string[] {
|
|
166
|
+
const plan = sanitizeAgentTaskPlanInput(input)
|
|
167
|
+
if (plan.tasks.length === 0) return []
|
|
168
|
+
|
|
169
|
+
this.tasks.clear()
|
|
170
|
+
this.toolCallToTaskId.clear()
|
|
171
|
+
this.taskToolNames.clear()
|
|
172
|
+
this.snapshotEmitted = false
|
|
173
|
+
this.hasAgentAuthoredPlan = true
|
|
174
|
+
|
|
175
|
+
plan.tasks.forEach((task, index) => {
|
|
176
|
+
const id = this.makeUniqueTaskId(task.id ?? `agent-plan-${index + 1}`)
|
|
177
|
+
const snapshot: ServerTaskSnapshot = {
|
|
178
|
+
id,
|
|
179
|
+
label: task.label,
|
|
180
|
+
state: 'pending',
|
|
181
|
+
source: 'agent',
|
|
182
|
+
detail: task.detail,
|
|
183
|
+
}
|
|
184
|
+
this.tasks.set(id, { snapshot, emitted: false })
|
|
185
|
+
if (task.toolName) {
|
|
186
|
+
this.taskToolNames.set(id, task.toolName)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return this.emitFullSnapshot()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private resolveTaskIdForToolCall(toolCallId: string, toolName: string | undefined): string {
|
|
194
|
+
const existing = this.toolCallToTaskId.get(toolCallId)
|
|
195
|
+
if (existing) return existing
|
|
196
|
+
const plannedTaskId = this.findPlannedTaskId(toolName)
|
|
197
|
+
if (plannedTaskId) {
|
|
198
|
+
this.toolCallToTaskId.set(toolCallId, plannedTaskId)
|
|
199
|
+
return plannedTaskId
|
|
200
|
+
}
|
|
201
|
+
this.toolCallToTaskId.set(toolCallId, toolCallId)
|
|
202
|
+
return toolCallId
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private findPlannedTaskId(toolName: string | undefined): string | null {
|
|
206
|
+
if (!this.hasAgentAuthoredPlan) return null
|
|
207
|
+
const entries = Array.from(this.tasks.entries())
|
|
208
|
+
const isAvailable = (entry: AccumulatorEntry) => !TERMINAL_STATES.has(entry.snapshot.state)
|
|
209
|
+
if (toolName) {
|
|
210
|
+
const exactPending = entries.find(([id, entry]) => {
|
|
211
|
+
return entry.snapshot.state === 'pending' && this.taskToolNames.get(id) === toolName
|
|
212
|
+
})
|
|
213
|
+
if (exactPending) return exactPending[0]
|
|
214
|
+
const exactAvailable = entries.find(([id, entry]) => {
|
|
215
|
+
return isAvailable(entry) && this.taskToolNames.get(id) === toolName
|
|
216
|
+
})
|
|
217
|
+
if (exactAvailable) return exactAvailable[0]
|
|
218
|
+
}
|
|
219
|
+
const genericPending = entries.find(([id, entry]) => {
|
|
220
|
+
return entry.snapshot.state === 'pending' && !this.taskToolNames.has(id)
|
|
221
|
+
})
|
|
222
|
+
if (genericPending) return genericPending[0]
|
|
223
|
+
const genericAvailable = entries.find(([id, entry]) => {
|
|
224
|
+
return isAvailable(entry) && !this.taskToolNames.has(id)
|
|
225
|
+
})
|
|
226
|
+
return genericAvailable?.[0] ?? null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private existingSnapshot(taskId: string): ServerTaskSnapshot | undefined {
|
|
230
|
+
return this.tasks.get(taskId)?.snapshot
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Apply a tool lifecycle chunk. Returns the SSE event lines (already
|
|
235
|
+
* `data: ...\n\n`-formatted) that should be injected ahead of forwarding
|
|
236
|
+
* the original chunk to the client.
|
|
237
|
+
*/
|
|
238
|
+
handleToolChunk(chunk: ToolChunk): string[] {
|
|
239
|
+
if (!chunk || typeof chunk.type !== 'string') return []
|
|
240
|
+
const toolCallId = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : null
|
|
241
|
+
const toolName = normalizeTaskPlanToolName(chunk.toolName)
|
|
242
|
+
if (isTaskPlanToolName(toolName)) {
|
|
243
|
+
if (toolCallId) this.internalToolCallIds.add(toolCallId)
|
|
244
|
+
if (chunk.type === 'tool-input-available') {
|
|
245
|
+
return this.handleAgentAuthoredPlan(chunk.input)
|
|
246
|
+
}
|
|
247
|
+
return []
|
|
248
|
+
}
|
|
249
|
+
if (!toolCallId) return []
|
|
250
|
+
if (this.internalToolCallIds.has(toolCallId)) return []
|
|
251
|
+
const taskId = this.resolveTaskIdForToolCall(toolCallId, toolName)
|
|
252
|
+
const existing = this.existingSnapshot(taskId)
|
|
253
|
+
const source = existing?.source ?? 'runtime'
|
|
254
|
+
const runtimeLabel = deriveTaskLabel(toolName)
|
|
255
|
+
const runtimeDetail = toolName
|
|
256
|
+
let nextSnapshot: ServerTaskSnapshot | null = null
|
|
257
|
+
switch (chunk.type) {
|
|
258
|
+
case 'tool-input-start':
|
|
259
|
+
nextSnapshot = this.upsert(taskId, {
|
|
260
|
+
label: source === 'agent' ? existing?.label : runtimeLabel,
|
|
261
|
+
state: 'running',
|
|
262
|
+
source,
|
|
263
|
+
toolCallId,
|
|
264
|
+
detail: source === 'agent' ? existing?.detail : runtimeDetail,
|
|
265
|
+
})
|
|
266
|
+
break
|
|
267
|
+
case 'tool-input-available':
|
|
268
|
+
// Runtime-derived tasks can refine the label when the SDK includes a
|
|
269
|
+
// richer toolName on input-available. Agent-authored tasks keep the
|
|
270
|
+
// safe label the model supplied through `meta.update_task_plan`.
|
|
271
|
+
nextSnapshot = this.upsert(taskId, {
|
|
272
|
+
label: source === 'agent' ? existing?.label : runtimeLabel,
|
|
273
|
+
state: 'running',
|
|
274
|
+
source,
|
|
275
|
+
toolCallId,
|
|
276
|
+
detail: source === 'agent' ? existing?.detail : runtimeDetail,
|
|
277
|
+
})
|
|
278
|
+
break
|
|
279
|
+
case 'tool-output-available':
|
|
280
|
+
nextSnapshot = this.upsert(taskId, {
|
|
281
|
+
state: 'done',
|
|
282
|
+
source,
|
|
283
|
+
toolCallId,
|
|
284
|
+
})
|
|
285
|
+
break
|
|
286
|
+
case 'tool-output-error':
|
|
287
|
+
case 'tool-input-error':
|
|
288
|
+
nextSnapshot = this.upsert(taskId, {
|
|
289
|
+
state: 'failed',
|
|
290
|
+
source,
|
|
291
|
+
toolCallId,
|
|
292
|
+
})
|
|
293
|
+
break
|
|
294
|
+
default:
|
|
295
|
+
return []
|
|
296
|
+
}
|
|
297
|
+
if (!nextSnapshot) return []
|
|
298
|
+
return this.emitForSnapshot(taskId, nextSnapshot)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private emitForSnapshot(id: string, snapshot: ServerTaskSnapshot): string[] {
|
|
302
|
+
const entry = this.tasks.get(id)
|
|
303
|
+
if (!entry) return []
|
|
304
|
+
const lines: string[] = []
|
|
305
|
+
if (!this.snapshotEmitted) {
|
|
306
|
+
return this.emitFullSnapshot()
|
|
307
|
+
}
|
|
308
|
+
if (!entry.emitted) {
|
|
309
|
+
// First time we surface this task: it must be part of the next snapshot
|
|
310
|
+
// refresh, but to keep the protocol minimal we emit a single
|
|
311
|
+
// `data-agent-task-update` carrying the new task — clients merge by id.
|
|
312
|
+
lines.push(
|
|
313
|
+
formatSseEvent({
|
|
314
|
+
type: 'data-agent-task-update',
|
|
315
|
+
planId: this.planId,
|
|
316
|
+
task: snapshot,
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
319
|
+
entry.emitted = true
|
|
320
|
+
return lines
|
|
321
|
+
}
|
|
322
|
+
lines.push(
|
|
323
|
+
formatSseEvent({
|
|
324
|
+
type: 'data-agent-task-update',
|
|
325
|
+
planId: this.planId,
|
|
326
|
+
task: snapshot,
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
return lines
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function formatSseEvent(payload: Record<string, unknown>): string {
|
|
334
|
+
return `data: ${JSON.stringify(payload)}\n\n`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Wrap a streaming `Response` and interleave `data-agent-task-plan` /
|
|
339
|
+
* `data-agent-task-update` SSE chunks. The wrapper does not consume the
|
|
340
|
+
* stream — it pipes bytes through and only parses event boundaries to know
|
|
341
|
+
* when to inject extra chunks.
|
|
342
|
+
*/
|
|
343
|
+
export function injectTaskPlanIntoStream(
|
|
344
|
+
baseResponse: Response,
|
|
345
|
+
planId: string,
|
|
346
|
+
): Response {
|
|
347
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
|
|
348
|
+
const writer = writable.getWriter()
|
|
349
|
+
const accumulator = new TaskPlanAccumulator(planId)
|
|
350
|
+
|
|
351
|
+
async function pump(): Promise<void> {
|
|
352
|
+
if (!baseResponse.body) {
|
|
353
|
+
await writer.close()
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const reader = baseResponse.body.getReader()
|
|
357
|
+
let textBuffer = ''
|
|
358
|
+
try {
|
|
359
|
+
for (;;) {
|
|
360
|
+
const { value, done } = await reader.read()
|
|
361
|
+
if (done) break
|
|
362
|
+
if (!value) continue
|
|
363
|
+
textBuffer += SSE_DECODER.decode(value, { stream: true })
|
|
364
|
+
textBuffer = await flushBuffer(textBuffer, accumulator, writer)
|
|
365
|
+
}
|
|
366
|
+
const tail = SSE_DECODER.decode()
|
|
367
|
+
if (tail) {
|
|
368
|
+
textBuffer += tail
|
|
369
|
+
}
|
|
370
|
+
if (textBuffer.length > 0) {
|
|
371
|
+
// Best-effort flush of any trailing bytes (the AI SDK always
|
|
372
|
+
// terminates events with `\n\n` so this path is rare).
|
|
373
|
+
await writer.write(SSE_ENCODER.encode(textBuffer))
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
// Surface upstream aborts to the downstream consumer by closing the
|
|
377
|
+
// writer — propagating the error would corrupt the SSE stream.
|
|
378
|
+
} finally {
|
|
379
|
+
reader.releaseLock()
|
|
380
|
+
await writer.close().catch(() => undefined)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
void pump()
|
|
385
|
+
return new Response(readable, {
|
|
386
|
+
status: baseResponse.status,
|
|
387
|
+
headers: baseResponse.headers,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function flushBuffer(
|
|
392
|
+
buffer: string,
|
|
393
|
+
accumulator: TaskPlanAccumulator,
|
|
394
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
395
|
+
): Promise<string> {
|
|
396
|
+
let rest = buffer
|
|
397
|
+
for (;;) {
|
|
398
|
+
const boundary = rest.indexOf('\n\n')
|
|
399
|
+
if (boundary === -1) break
|
|
400
|
+
const eventBlock = rest.slice(0, boundary + 2)
|
|
401
|
+
rest = rest.slice(boundary + 2)
|
|
402
|
+
const injected = inspectEventBlock(eventBlock, accumulator)
|
|
403
|
+
for (const line of injected.before) {
|
|
404
|
+
await writer.write(SSE_ENCODER.encode(line))
|
|
405
|
+
}
|
|
406
|
+
await writer.write(SSE_ENCODER.encode(eventBlock))
|
|
407
|
+
for (const line of injected.after) {
|
|
408
|
+
await writer.write(SSE_ENCODER.encode(line))
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return rest
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
interface InjectedLines {
|
|
415
|
+
before: string[]
|
|
416
|
+
after: string[]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function inspectEventBlock(
|
|
420
|
+
eventBlock: string,
|
|
421
|
+
accumulator: TaskPlanAccumulator,
|
|
422
|
+
): InjectedLines {
|
|
423
|
+
const dataPayload = extractDataPayload(eventBlock)
|
|
424
|
+
if (!dataPayload || dataPayload === '[DONE]') {
|
|
425
|
+
return { before: [], after: [] }
|
|
426
|
+
}
|
|
427
|
+
let parsed: ToolChunk | null = null
|
|
428
|
+
try {
|
|
429
|
+
parsed = JSON.parse(dataPayload)
|
|
430
|
+
} catch {
|
|
431
|
+
return { before: [], after: [] }
|
|
432
|
+
}
|
|
433
|
+
if (!parsed || typeof parsed.type !== 'string') {
|
|
434
|
+
return { before: [], after: [] }
|
|
435
|
+
}
|
|
436
|
+
const type = parsed.type
|
|
437
|
+
const injected = accumulator.handleToolChunk(parsed)
|
|
438
|
+
if (injected.length === 0) {
|
|
439
|
+
return { before: [], after: [] }
|
|
440
|
+
}
|
|
441
|
+
// Tool-input-start gets the plan event BEFORE the original (so the row
|
|
442
|
+
// appears at the same time as the tool starts). Output / error events
|
|
443
|
+
// get the plan event AFTER so the row updates only once the tool result
|
|
444
|
+
// is visible in the existing tool-call detail row.
|
|
445
|
+
if (type === 'tool-input-start' || type === 'tool-input-available') {
|
|
446
|
+
return { before: injected, after: [] }
|
|
447
|
+
}
|
|
448
|
+
return { before: [], after: injected }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function extractDataPayload(eventBlock: string): string | null {
|
|
452
|
+
const lines = eventBlock.split('\n')
|
|
453
|
+
const dataLines: string[] = []
|
|
454
|
+
for (const line of lines) {
|
|
455
|
+
if (line.startsWith('data: ')) {
|
|
456
|
+
dataLines.push(line.slice(6))
|
|
457
|
+
} else if (line.startsWith('data:')) {
|
|
458
|
+
dataLines.push(line.slice(5))
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (dataLines.length === 0) return null
|
|
462
|
+
return dataLines.join('\n')
|
|
463
|
+
}
|
|
@@ -44,6 +44,9 @@ export const toolFixtures: Record<string, ToolFixture> = {
|
|
|
44
44
|
'meta.describe_agent': f({
|
|
45
45
|
input: { agentId: 'customers.account_assistant' },
|
|
46
46
|
}),
|
|
47
|
+
'meta.update_task_plan': f({
|
|
48
|
+
input: { tasks: [{ label: 'Search records', toolName: 'customers.list_people' }] },
|
|
49
|
+
}),
|
|
47
50
|
'attachments.list_record_attachments': f({
|
|
48
51
|
input: { entityType: 'customers:person', recordId: '00000000-0000-0000-0000-000000000000', limit: 5 },
|
|
49
52
|
note: 'Empty result is a valid response; we only assert shape.',
|
|
@@ -54,6 +54,8 @@ export interface AiToolLoadBeforeRecord {
|
|
|
54
54
|
label: string
|
|
55
55
|
recordVersion: string | null
|
|
56
56
|
before: Record<string, unknown>
|
|
57
|
+
after?: Record<string, unknown>
|
|
58
|
+
display?: AiToolFieldDiffDisplayHints
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
@@ -67,6 +69,20 @@ export interface AiToolLoadBeforeSingleRecord {
|
|
|
67
69
|
entityType: string
|
|
68
70
|
recordVersion: string | null
|
|
69
71
|
before: Record<string, unknown>
|
|
72
|
+
after?: Record<string, unknown>
|
|
73
|
+
display?: AiToolFieldDiffDisplayHints
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Optional display hints for mutation-preview diffs. Raw `before` / `after`
|
|
78
|
+
* values remain persisted for execution and stale checks; these labels are
|
|
79
|
+
* only for operator-facing cards, e.g. showing a pipeline stage name instead
|
|
80
|
+
* of its UUID.
|
|
81
|
+
*/
|
|
82
|
+
export interface AiToolFieldDiffDisplayHints {
|
|
83
|
+
fieldLabels?: Record<string, string>
|
|
84
|
+
before?: Record<string, unknown>
|
|
85
|
+
after?: Record<string, unknown>
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
/**
|