@frontera-sdk/cli 1.52.39 → 1.52.40

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.
Files changed (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,700 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { itemBuild } from '../../local/local-build-summary'
5
+ import type { LocalProject } from '../../local/local-project'
6
+ import { providerRefusal } from '../../local/local-provider-refusal'
7
+ import { plainVariables, redactedVariablesOfTimeline, variablesOfTimeline } from '../../local/local-test-cases'
8
+ import { chatTarget, type ToolCall } from '../../local/local-test-runner'
9
+ import { dropTurn, readTurn, reattachTurn, startTurn, TURN_IDLE_MS, type HeldTurn, type PendingDecision, type TurnRead } from '../mcp-chat'
10
+ import { errorResult, intArg, jsonResult, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
11
+ import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
12
+ import { awaitTurnTrace, conversationTraces, getTrace, listTraces, proceduresOf, readTrace, replyCheckOf, replyChecksOfTrace, type ReplyCheck } from '../mcp-traces'
13
+
14
+ /**
15
+ * Talk to an Agent and read what it did: the coding agent changes an Agent,
16
+ * chats with its draft on the local runtime, and reads the trace, so it can
17
+ * tell whether the change made things better without asking the person to
18
+ * open Studio. Every answer carries the Studio links the person can open.
19
+ */
20
+
21
+ /** One message of a conversation as `agent_chat` saw it, in the stored messages' shape. */
22
+ export interface SeenMessage {
23
+ role: 'user' | 'assistant'
24
+ parts: Array<{ type: string; text?: string; toolName?: string; input?: unknown; state?: string }>
25
+ }
26
+
27
+ /**
28
+ * What this MCP connection remembers between calls: the last conversation
29
+ * with each Agent and its last turn (for `test_save_case`), each
30
+ * conversation's agent and transcript as `agent_chat` saw it (a test
31
+ * conversation goes when the draft's test copy is replaced, so a case can
32
+ * still be saved from it), and the turns that wait on the person's answer.
33
+ */
34
+ export interface McpMemory {
35
+ lastConversation: Map<string, { conversationId: string; agentId: string; at: string; lastTurn?: { reply: string; toolCalls: ToolCall[] } }>
36
+ conversations: Map<string, { agent: string; agentId: string; agentName: string; live: boolean; transcript: SeenMessage[]; realActions?: boolean; realActionsNote?: string | null; variables?: Record<string, unknown> }>
37
+ waiting: Map<string, { held: HeldTurn; pending: PendingDecision; since: string; before: string[] }>
38
+ }
39
+
40
+ export function createMemory(): McpMemory {
41
+ return { lastConversation: new Map(), conversations: new Map(), waiting: new Map() }
42
+ }
43
+
44
+ export type Decision = 'approve' | 'deny' | 'confirm' | 'cancel'
45
+ export const DECISIONS: readonly Decision[] = ['approve', 'deny', 'confirm', 'cancel']
46
+
47
+ /** What a decision answers: an approval card (approve, deny) or a person confirmation (confirm, cancel). */
48
+ export function decisionKind(decision: Decision): PendingDecision['kind'] {
49
+ return decision === 'approve' || decision === 'deny' ? 'approval' : 'confirmation'
50
+ }
51
+
52
+ /** Everything that waits on the person in a conversation, as the runtime lists it: confirmations, then approvals. */
53
+ async function pendingOnRuntime(link: RuntimeLink, conversationId: string): Promise<PendingDecision[]> {
54
+ const q = `conversationId=${encodeURIComponent(conversationId)}`
55
+ const confirmations = await serviceCall<Array<{ id: string; status: string; summary: string; details: string | null; confirmLabel: string; cancelLabel: string; expiresAt?: string }>>(link, `/v1/chat/confirmations?${q}`, { timeoutMs: 15_000 }).catch(() => [])
56
+ const approvals = await serviceCall<Array<{ id: string; status: string; pauseReason?: string; policyReason?: string | null; askedBecause?: string | null; toolCallId: string | null; toolName: string | null; actionSummary?: string | null; expiresAt?: string | null }>>(link, `/v1/chat/approvals?${q}`, { timeoutMs: 15_000 }).catch(() => [])
57
+ return [
58
+ ...(Array.isArray(confirmations) ? confirmations : [])
59
+ .filter((c) => c.status === 'pending')
60
+ .map((c): PendingDecision => ({ kind: 'confirmation', id: c.id, summary: c.summary, details: c.details, confirmLabel: c.confirmLabel, cancelLabel: c.cancelLabel, ...(c.expiresAt ? { expiresAt: c.expiresAt } : {}) })),
61
+ ...(Array.isArray(approvals) ? approvals : [])
62
+ .filter((a) => a.status === 'pending' && (!a.pauseReason || a.pauseReason === 'needs_approval'))
63
+ .map((a): PendingDecision => ({ kind: 'approval', id: a.id, toolCallId: a.toolCallId, tool: a.toolName, summary: a.actionSummary ?? null, ...(a.expiresAt ? { expiresAt: String(a.expiresAt) } : {}), ...askedOf(a.policyReason ?? null, a.askedBecause ?? null) })),
64
+ ]
65
+ }
66
+
67
+ /**
68
+ * What waits, as the agent reads it, and how to decide it: `agent_decide`,
69
+ * an explicit call per decision. Nothing here ever decides for the person.
70
+ */
71
+ /** Why an approval card asked, from the runtime's answer: its reason and whether the binding or the chat's approval mode asked. */
72
+ function askedOf(reason: string | null, askedBecause: string | null): { reason?: string; askedBecause?: 'binding' | 'approval-mode' } {
73
+ return {
74
+ ...(reason ? { reason } : {}),
75
+ ...(askedBecause === 'binding' || askedBecause === 'approval-mode' ? { askedBecause } : {}),
76
+ }
77
+ }
78
+
79
+ export function pendingDecisionView(pending: PendingDecision, conversationId: string): Record<string, unknown> {
80
+ const choices = pending.kind === 'approval' ? ['approve', 'deny'] : ['confirm', 'cancel']
81
+ return {
82
+ kind: pending.kind,
83
+ id: pending.id,
84
+ toolName: pending.kind === 'approval' ? pending.tool : null,
85
+ summary: pending.summary || null,
86
+ ...(pending.kind === 'confirmation' && pending.details ? { details: pending.details } : {}),
87
+ ...(pending.kind === 'confirmation' ? { labels: { confirm: pending.confirmLabel, cancel: pending.cancelLabel } } : {}),
88
+ expiresAt: pending.expiresAt ?? null,
89
+ ...(pending.kind === 'approval' && pending.reason ? { reason: pending.reason } : {}),
90
+ ...(pending.kind === 'approval' && pending.askedBecause
91
+ ? { askedBecause: pending.askedBecause === 'binding' ? 'the binding asks for approval before each run' : 'the chat\'s approval mode asks before a change (an Unattended binding keeps it only in a dataset run on the published Agent)' }
92
+ : {}),
93
+ decide: `agent_decide { "conversationId": "${conversationId}", "id": "${pending.id}", "decision": "${choices[0]}" | "${choices[1]}" }: decide it as the person would on the card. Typed words never answer a card.`,
94
+ }
95
+ }
96
+
97
+ /** The runtime's 409 `decision_pending` for a message sent while a card waits: what waits, or null for any other answer. */
98
+ export function pendingFromConflict(message: string): PendingDecision[] | null {
99
+ if (!/answered 409/.test(message)) return null
100
+ const json = message.slice(message.indexOf('{'))
101
+ try {
102
+ const body = JSON.parse(json) as { details?: { reason?: string; pending?: Array<{ kind?: string; id?: string; toolName?: string | null; summary?: string | null; expiresAt?: string | null; reason?: string | null; askedBecause?: string | null }> } }
103
+ if (body.details?.reason !== 'decision_pending') return null
104
+ return (body.details.pending ?? [])
105
+ .filter((p) => typeof p.id === 'string')
106
+ .map((p): PendingDecision =>
107
+ p.kind === 'confirmation'
108
+ ? { kind: 'confirmation', id: p.id!, summary: p.summary ?? '', details: null, confirmLabel: 'Confirm', cancelLabel: 'Cancel', ...(p.expiresAt ? { expiresAt: p.expiresAt } : {}) }
109
+ : { kind: 'approval', id: p.id!, toolCallId: null, tool: p.toolName ?? null, summary: p.summary ?? null, ...(p.expiresAt ? { expiresAt: p.expiresAt } : {}), ...askedOf(p.reason ?? null, p.askedBecause ?? null) },
110
+ )
111
+ } catch {
112
+ // The body was clipped, or is not the runtime's: the reason is in the words.
113
+ return /decision_pending/.test(message) ? [] : null
114
+ }
115
+ }
116
+
117
+ /** The answer when a message cannot go on until the person decides. */
118
+ function decideFirst(conversationId: string, pending: PendingDecision[]): ToolResult {
119
+ return {
120
+ isError: true,
121
+ content: [
122
+ {
123
+ type: 'text',
124
+ text: JSON.stringify(
125
+ {
126
+ conversationId,
127
+ status: 'decision_pending',
128
+ why: 'The Agent waits on the person in this conversation, so a new message cannot go on until it is decided. Decide it with agent_decide (approve or deny a tool, confirm or cancel a confirmation), or start a new conversation.',
129
+ pendingDecisions: pending.map((p) => pendingDecisionView(p, conversationId)),
130
+ },
131
+ null,
132
+ 2,
133
+ ),
134
+ },
135
+ ],
136
+ }
137
+ }
138
+
139
+ /** The stored conversation's agent and run, or null when it is gone. */
140
+ async function storedConversation(link: RuntimeLink, conversationId: string): Promise<{ agentName: string | null; activeRunId: string | null } | null> {
141
+ try {
142
+ const s = await serviceCall<{ agentName?: string | null; activeRunId?: string | null }>(link, `/v1/sessions/${encodeURIComponent(conversationId)}`, { timeoutMs: 15_000 })
143
+ return { agentName: s.agentName ?? null, activeRunId: s.activeRunId ?? null }
144
+ } catch (err) {
145
+ if (/answered 404/.test((err as Error).message)) return null
146
+ throw err
147
+ }
148
+ }
149
+
150
+ /** Why a conversation is gone, in words the agent can act on. */
151
+ export function goneConversation(conversationId: string): string {
152
+ return `Conversation ${conversationId} is no longer on the local runtime. A conversation with a draft lives on the draft's test copy, which is replaced when the draft changes (an apply), when the Agent is published, or after an hour unused; its conversations go with it. Start a new one: agent_chat without conversationId.`
153
+ }
154
+
155
+ /**
156
+ * The model the Agent's file names: the mode its `config.defaultMode` names,
157
+ * which a chat without a chosen mode runs on; else its first mode by sort order.
158
+ */
159
+ export function configuredModel(root: string, slug: string): string | null {
160
+ try {
161
+ const doc = JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as {
162
+ config?: { defaultMode?: unknown }
163
+ modelConfigs?: Array<{ modelSlug?: unknown; modeKey?: unknown; sortOrder?: unknown }>
164
+ }
165
+ const modes = (doc.modelConfigs ?? []).filter((m) => typeof m.modelSlug === 'string')
166
+ const defaultMode = doc.config?.defaultMode
167
+ const named = typeof defaultMode === 'string' ? modes.find((m) => m.modeKey === defaultMode) : undefined
168
+ const first = named ?? [...modes].sort((a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0))[0]
169
+ return (first?.modelSlug as string | undefined) ?? null
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+
175
+ /** The model the local runtime runs instead, from what the last apply recorded ("…names the model X, which this runtime does not have; it uses Y here"). */
176
+ export function substitutedModel(adaptations: readonly string[], configured: string): string | null {
177
+ for (const text of adaptations) {
178
+ const match = /names the model (\S+), which this runtime does not have; it uses (\S+) here/i.exec(text)
179
+ if (match && match[1] === configured) return match[2]!
180
+ }
181
+ return null
182
+ }
183
+
184
+ /** What the Agent's file asks for and what this turn ran on, when they differ or the runtime says so. */
185
+ function modelOf(project: LocalProject, slug: string, ranOn: string[]) {
186
+ const configured = configuredModel(project.root, slug)
187
+ if (!configured) return ranOn.length ? { configured: null, used: ranOn[0]!, ...(ranOn.length > 1 ? { ranOn } : {}) } : null
188
+ const swapped = substitutedModel(itemBuild(project.root, `agent/${slug}`, project.id)?.adaptations ?? [], configured)
189
+ return {
190
+ configured,
191
+ used: swapped ?? configured,
192
+ ...(swapped ? { substituted: true, why: `The local runtime has no ${configured}; this Agent runs on ${swapped} here. Replies may differ from the workspace.` } : {}),
193
+ ...(ranOn.length ? { ranOn } : {}),
194
+ }
195
+ }
196
+
197
+ /** The tools a turn was offered, from its \`pi.turn N\` spans' metadata (sorted names), or null when the runtime did not record them. */
198
+ export function toolsOfferedOf(spans: Array<{ name: string; metadata?: Record<string, unknown> }>): string[] | null {
199
+ const names = new Set<string>()
200
+ let seen = false
201
+ for (const span of spans) {
202
+ if (!/^pi\.turn\b/.test(span.name)) continue
203
+ const offered = span.metadata?.toolsOffered
204
+ if (!Array.isArray(offered)) continue
205
+ seen = true
206
+ for (const n of offered) if (typeof n === 'string') names.add(n)
207
+ }
208
+ return seen ? [...names].sort() : null
209
+ }
210
+
211
+ /**
212
+ * What the reply check did this turn: from the session's \`reply.blocked\`
213
+ * events after \`since\`, else the turn's \`reply.blocked\` spans (the same
214
+ * fields, on the trace). The last check, read as the agent needs it — the
215
+ * check, what it found, what the person got, both drafts (clipped) — with the
216
+ * earlier ones this turn when there were several. Null when no check fired.
217
+ */
218
+ export function replyBlockedOf(
219
+ timeline: Array<{ type?: string; createdAt?: string; payload?: Record<string, unknown> | null }>,
220
+ since: string,
221
+ spans: Array<{ name: string; startTime?: string | null; level?: string; statusMessage?: string | null; output?: unknown; metadata?: Record<string, unknown> | null }> = [],
222
+ ): (ReplyCheck & { times?: number; earlier?: ReplyCheck[] }) | null {
223
+ const events = timeline.filter((e) => e.type === 'reply.blocked' && (!e.createdAt || Date.parse(e.createdAt) >= Date.parse(since)))
224
+ const checks = events.length ? events.map((e) => replyCheckOf(e.payload ?? {}, e.createdAt)) : replyChecksOfTrace(spans)
225
+ const last = checks.at(-1)
226
+ if (!last) return null
227
+ return checks.length > 1 ? { ...last, times: checks.length, earlier: checks.slice(0, -1) } : last
228
+ }
229
+
230
+ /**
231
+ * Whether a test conversation runs actions for real, from its Test setup
232
+ * (the `TRY_SETUP` event the Try route records when the conversation starts):
233
+ * true or false, or null when the conversation has no setup recorded.
234
+ */
235
+ export function realActionsOfTimeline(timeline: Array<{ type?: string; payload?: Record<string, unknown> | null }>): boolean | null {
236
+ const setup = timeline.find((e) => e.payload?.event === 'TRY_SETUP')
237
+ if (!setup) return null
238
+ return setup.payload?.simulateActions === false
239
+ }
240
+
241
+ /** Whether a conversation runs actions for real: as this connection started it, else as its Test setup recorded. */
242
+ export async function conversationRunsRealActions(memory: McpMemory, link: RuntimeLink, agentId: string, conversationId: string): Promise<{ realActions: boolean; realActionsNote: string | null }> {
243
+ const known = memory.conversations.get(conversationId)
244
+ if (known?.realActions !== undefined) return { realActions: known.realActions, realActionsNote: known.realActionsNote ?? null }
245
+ const recorded = agentId ? realActionsOfTimeline(await sessionTimeline(link, agentId, conversationId)) : null
246
+ if (known && recorded !== null) known.realActions = recorded
247
+ return { realActions: recorded === true, realActionsNote: null }
248
+ }
249
+
250
+ /**
251
+ * The variables a conversation started with (`customer_id: CST-00028`), so a
252
+ * case saved from it replays with them: as this connection set them, else as
253
+ * its Test setup recorded them (`variables` on the `TRY_SETUP` event, when the
254
+ * runtime records them). Only plain values, which is what a case file holds;
255
+ * `redacted` names the sensitive ones the setup recorded without a value.
256
+ */
257
+ export async function conversationVariables(
258
+ memory: McpMemory,
259
+ link: RuntimeLink,
260
+ agentId: string,
261
+ conversationId: string,
262
+ ): Promise<{ variables: Record<string, string | number | boolean> | undefined; redacted: string[] }> {
263
+ const known = memory.conversations.get(conversationId)?.variables
264
+ if (known) return { variables: plainVariables(known), redacted: [] }
265
+ if (!agentId) return { variables: undefined, redacted: [] }
266
+ // Read from the Test setup: a sensitive variable is named there (`redactedVariables`), never its value.
267
+ const timeline = await sessionTimeline(link, agentId, conversationId)
268
+ return { variables: variablesOfTimeline(timeline), redacted: redactedVariablesOfTimeline(timeline) }
269
+ }
270
+
271
+ /** The conversation's session events, through the Agent's Try route; none when it cannot be read. */
272
+ async function sessionTimeline(link: RuntimeLink, agentId: string, conversationId: string) {
273
+ try {
274
+ const session = await serviceCall<{ timeline?: Array<{ type?: string; createdAt?: string; payload?: Record<string, unknown> | null }> } | null>(
275
+ link,
276
+ `/v1/config/agents/${encodeURIComponent(agentId)}/try/conversations/${encodeURIComponent(conversationId)}/session`,
277
+ { timeoutMs: 15_000 },
278
+ )
279
+ return session?.timeline ?? []
280
+ } catch {
281
+ return []
282
+ }
283
+ }
284
+
285
+ /**
286
+ * What the reply check did to a conversation's last reply, for a test case
287
+ * that failed: the checks of the last turn (after its last
288
+ * \`message.received\`), and how many fired on earlier turns. Read from the
289
+ * session timeline when it records one, else from the conversation's traces
290
+ * (a test copy that is gone keeps its traces). Null when no check fired.
291
+ */
292
+ export function lastReplyChecks(
293
+ timeline: Array<{ type?: string; createdAt?: string; payload?: Record<string, unknown> | null }>,
294
+ traces: Array<{ timestamp: string; observations: Array<{ name: string; type?: string; startTime?: string | null; metadata?: Record<string, unknown> | null; statusMessage?: string | null }> }>,
295
+ ): { lastReply: string; checks: ReplyCheck[]; earlierTurns?: number } | null {
296
+ let checks: ReplyCheck[] = []
297
+ let earlier = 0
298
+ if (timeline.some((e) => e.type === 'reply.blocked')) {
299
+ const lastMessage = timeline.map((e) => e.type).lastIndexOf('message.received')
300
+ timeline.forEach((e, i) => {
301
+ if (e.type !== 'reply.blocked') return
302
+ if (i > lastMessage) checks.push(replyCheckOf(e.payload ?? {}, e.createdAt))
303
+ else earlier += 1
304
+ })
305
+ } else {
306
+ // A turn's trace has its \`pi.turn N\` spans (a title or summary written afterwards is a trace without them); the newest is the last turn.
307
+ const isTurn = (t: (typeof traces)[number]) => t.observations.some((o) => /^pi\.turn\b/.test(o.name))
308
+ const turns = (traces.some(isTurn) ? traces.filter(isTurn) : traces).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
309
+ const blocked = turns.map((t) => replyChecksOfTrace(t.observations))
310
+ checks = blocked.at(-1) ?? []
311
+ earlier = blocked.slice(0, -1).reduce((n, list) => n + list.length, 0)
312
+ }
313
+ if (!checks.length && !earlier) return null
314
+ const last = checks.at(-1)
315
+ const lastReply = !last
316
+ ? 'not changed by the reply check (it fired on an earlier turn)'
317
+ : last.replacedWithFixedReply
318
+ ? `replaced with the fixed safe reply: the ${last.check} check failed both drafts`
319
+ : `changed by the reply check (${last.check}, ${last.outcome}): the graded reply is not the Agent's first draft`
320
+ return { lastReply, checks, ...(earlier ? { earlierTurns: earlier } : {}) }
321
+ }
322
+
323
+ /** The reply checks of a test case's conversation, read as \`lastReplyChecks\` does; null when nothing can be read. */
324
+ export async function caseReplyChecks(link: RuntimeLink, agentId: string | undefined, conversationId: string | undefined) {
325
+ if (!conversationId) return null
326
+ const timeline = agentId ? await sessionTimeline(link, agentId, conversationId) : []
327
+ // The trace records a check even when the session record of it was lost.
328
+ return lastReplyChecks(timeline, []) ?? lastReplyChecks([], await conversationTraces(link, conversationId).catch(() => []))
329
+ }
330
+
331
+ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[] {
332
+ /**
333
+ * The turn as the agent reads it: the reply so far, what the Agent waits
334
+ * on (kept, with its stream, for the answer), or why the turn stopped
335
+ * without finishing; then the trace, the model and the links.
336
+ */
337
+ async function finishTurn(args: {
338
+ conversationId: string
339
+ agentId: string
340
+ held: HeldTurn
341
+ read: TurnRead
342
+ since: string
343
+ before: string[]
344
+ started: number
345
+ live: boolean
346
+ realActions: boolean
347
+ realActionsNote: string | null
348
+ answered?: Record<string, unknown>
349
+ }): Promise<ToolResult> {
350
+ const link = requireRuntime(project)
351
+ const { conversationId, read } = args
352
+ const slug = memory.conversations.get(conversationId)?.agent ?? ''
353
+ const ms = Date.now() - args.started
354
+ memory.lastConversation.set(slug, { conversationId, agentId: args.agentId, at: new Date().toISOString(), lastTurn: { reply: read.reply, toolCalls: read.toolCalls } })
355
+ const remembered = memory.conversations.get(conversationId)
356
+ if (read.end.status === 'waiting') {
357
+ const end = read.end
358
+ // The stream names the card; the runtime's list adds what it says and when it expires.
359
+ const listed = (await pendingOnRuntime(link, conversationId)).find((p) => p.id === end.pending.id)
360
+ if (listed) end.pending = { ...listed, ...(end.pending.kind === 'approval' && end.pending.tool && listed.kind === 'approval' && !listed.tool ? { tool: end.pending.tool } : {}) } as PendingDecision
361
+ memory.waiting.set(conversationId, { held: args.held, pending: end.pending, since: args.since, before: args.before })
362
+ } else {
363
+ memory.waiting.delete(conversationId)
364
+ dropTurn(args.held)
365
+ if (remembered && (read.reply || read.toolCalls.length)) {
366
+ remembered.transcript.push({
367
+ role: 'assistant',
368
+ parts: [
369
+ ...read.toolCalls.map((t) => ({ type: `tool-${t.name}`, input: t.args, state: t.ok ? 'output-available' : 'output-error' })),
370
+ ...(read.reply ? [{ type: 'text', text: read.reply }] : []),
371
+ ],
372
+ })
373
+ }
374
+ }
375
+ // A turn that waits has not ended, so its trace may still be open: look, but do not wait long.
376
+ const turn = await awaitTurnTrace(link, conversationId, { before: new Set(args.before), since: args.since, ...(read.end.status === 'finished' ? {} : { waitMs: 3_000 }) })
377
+ const blocked = replyBlockedOf(await sessionTimeline(link, args.agentId, conversationId), args.since, turn?.trace.observations ?? [])
378
+ const model = modelOf(project, slug, turn?.models ?? [])
379
+ // The tools the turn was offered, as the runtime records them on each \`pi.turn N\` span.
380
+ const toolsOffered = toolsOfferedOf(turn?.trace.observations ?? [])
381
+ // Each action the draft did not run is an `action.simulated` span on the turn's trace.
382
+ const simulated = (turn?.trace.observations ?? []).filter((o) => o.name === 'action.simulated').map((o) => ({ ...(o.metadata ?? {}), ...(o.input ? { input: o.input } : {}) }))
383
+ // The procedure each model turn ran under, and why (`procedure.active`), and the ones the model loaded.
384
+ const procedures = proceduresOf(turn?.trace.observations ?? [])
385
+ const stopped =
386
+ read.end.status === 'idle'
387
+ ? `The runtime took the turn but sent nothing for ${Math.round(read.end.silentMs / 1000)} s${read.lastEvent ? ` after "${read.lastEvent}"` : ' (no event at all)'}${read.runId ? `; its run is ${read.runId}` : ''}. The Agent did not answer: trace_get the trace${turn ? '' : ' (trace_list with this conversationId finds it)'} and \`frontera local logs service\` show why. The conversation can go on once the run ends.`
388
+ : read.end.status === 'timeout'
389
+ ? `The turn did not finish within ${Math.round(read.end.elapsedMs / 1000)} s (events so far: ${JSON.stringify(read.events)}). It may still be running: trace_list with this conversationId shows it.`
390
+ : null
391
+ const result = {
392
+ agent: slug,
393
+ version: args.live ? 'live' : 'draft',
394
+ conversationId,
395
+ ...(args.answered ? { answered: args.answered } : {}),
396
+ reply: read.reply,
397
+ ...(read.end.status === 'waiting' ? { pendingDecision: pendingDecisionView(read.end.pending, conversationId), runId: read.runId } : {}),
398
+ ...(toolsOffered ? { toolsOffered } : {}),
399
+ ...(stopped ? { stopped, status: read.end.status } : {}),
400
+ ...(blocked ? { replyBlocked: blocked } : {}),
401
+ ...(procedures ? { procedure: procedures.active, ...(procedures.turns ? { procedureByTurn: procedures.turns } : {}), ...(procedures.loaded ? { proceduresLoaded: procedures.loaded } : {}) } : {}),
402
+ toolCalls: read.toolCalls.map((t) => ({ name: t.name, ok: t.ok, args: t.args })),
403
+ ...(args.live
404
+ ? {}
405
+ : {
406
+ actions:
407
+ args.realActions && !args.realActionsNote
408
+ ? 'real: this conversation runs Functions and Workflows that change something for real'
409
+ : 'Functions and Workflows that only read run for real; ones that change something are not run (realActions: true in a new conversation runs them)',
410
+ ...(args.realActionsNote ? { realActionsNote: args.realActionsNote } : {}),
411
+ ...(simulated.length ? { notRun: simulated } : {}),
412
+ }),
413
+ ...(model ? { model } : {}),
414
+ ms,
415
+ traceId: turn?.summary.id ?? null,
416
+ ...(turn
417
+ ? { tokens: turn.summary.tokens, ...(turn.tokensPartial ? { tokensPartial: true, tokensNote: 'Not all of the trace had arrived, or the model reported no usage: trace_get shows it whole.' } : {}) }
418
+ : { traceNote: 'The trace has not arrived yet; trace_list with this conversationId finds it shortly.' }),
419
+ links: {
420
+ conversation: studioLink(link, `/agents/${encodeURIComponent(args.agentId)}/c/${encodeURIComponent(conversationId)}`),
421
+ ...(turn ? { trace: turn.summary.link } : {}),
422
+ },
423
+ }
424
+ return stopped ? { ...jsonResult(result), isError: true } : jsonResult(result)
425
+ }
426
+
427
+ /**
428
+ * A turn the model provider refused (no credit, a rate limit, a refused key, an outage), said as its cause and
429
+ * fix like test_run's couldNotRun, with the model to switch to when another held key offers one; null for any
430
+ * other failure, which keeps its own words.
431
+ */
432
+ async function refusedTurn(err: unknown, args: { conversationId: string; agentId: string; since: string; before: string[] }): Promise<ToolResult | null> {
433
+ const link = requireRuntime(project)
434
+ const refusal = providerRefusal((err as Error)?.message ?? String(err), { providers: link.creds.modelProviders, again: 'send the message again (agent_chat)' })
435
+ if (!refusal) return null
436
+ memory.waiting.delete(args.conversationId)
437
+ const slug = memory.conversations.get(args.conversationId)?.agent ?? ''
438
+ // The refused turn still leaves a trace: name it when it is there, without waiting long.
439
+ const turn = await awaitTurnTrace(link, args.conversationId, { before: new Set(args.before), since: args.since, waitMs: 3_000 }).catch(() => null)
440
+ const { providerSaid, ...couldNotRun } = refusal
441
+ return {
442
+ ...jsonResult({
443
+ agent: slug,
444
+ conversationId: args.conversationId,
445
+ reply: '',
446
+ couldNotRun,
447
+ providerSaid,
448
+ note: 'The model provider refused this turn before the Agent replied: nothing about the Agent was tested. Keys and provider accounts are the person’s: tell them the fix.',
449
+ traceId: turn?.summary.id ?? null,
450
+ links: {
451
+ conversation: studioLink(link, `/agents/${encodeURIComponent(args.agentId)}/c/${encodeURIComponent(args.conversationId)}`),
452
+ ...(turn ? { trace: turn.summary.link } : {}),
453
+ },
454
+ }),
455
+ isError: true,
456
+ }
457
+ }
458
+
459
+ /** Decide what the Agent waits on, as the person would on its card, and read the rest of the turn. */
460
+ async function decide(conversationId: string, id: string, decision: Decision): Promise<ToolResult> {
461
+ const link = requireRuntime(project)
462
+ const waiting = memory.waiting.get(conversationId)
463
+ const listed = await pendingOnRuntime(link, conversationId)
464
+ const pending = (waiting?.pending.id === id ? waiting.pending : null) ?? listed.find((p) => p.id === id) ?? null
465
+ if (!pending) {
466
+ const stored = await storedConversation(link, conversationId)
467
+ if (!stored) return errorResult(goneConversation(conversationId))
468
+ const open = [...(waiting ? [waiting.pending] : []), ...listed.filter((p) => p.id !== waiting?.pending.id)]
469
+ return open.length
470
+ ? decideFirst(conversationId, open)
471
+ : errorResult(`Nothing in conversation ${conversationId} waits on a decision with id ${id}: it was decided, it expired, or the id is another conversation's. When the Agent asks in words ("Shall I go ahead?"), answer with agent_chat and a message.`)
472
+ }
473
+ if (decisionKind(decision) !== pending.kind) {
474
+ return errorResult(`${id} is ${pending.kind === 'approval' ? 'a tool approval: decide it with approve or deny' : 'a confirmation: decide it with confirm or cancel'}.`)
475
+ }
476
+ const path = pending.kind === 'confirmation' ? `/v1/chat/confirmations/${encodeURIComponent(pending.id)}` : `/v1/chat/approvals/${encodeURIComponent(pending.id)}`
477
+ const decided = await serviceCall<{ status?: string }>(link, path, { body: { decision } })
478
+ const answered = { kind: pending.kind, id: pending.id, decision, ...(decided?.status ? { status: decided.status } : {}) }
479
+ if (decided?.status === 'expired') {
480
+ memory.waiting.delete(conversationId)
481
+ return errorResult(`The ${pending.kind} ${pending.id} had expired before the decision arrived; the run went on without it. trace_list with this conversationId shows what it did.`)
482
+ }
483
+ const known = memory.conversations.get(conversationId)
484
+ const agentId = known?.agentId ?? ''
485
+ let held = waiting?.pending.id === id ? waiting.held : undefined
486
+ if (!held || !held.reader) {
487
+ // The stream is gone (another connection, or it dropped): the run keeps going; read the rest from its journal.
488
+ const stored = await storedConversation(link, conversationId)
489
+ const runId = held?.runId ?? stored?.activeRunId ?? null
490
+ if (!runId) return jsonResult({ conversationId, answered, reply: '', note: 'Decided. The run that waited has ended, so its reply is in the conversation: agent_chat on with a message, or trace_list with this conversationId.' })
491
+ held = await reattachTurn(link, held ?? { reader: null, buffer: '', runId, chunks: 0, reply: '', calls: new Map(), events: {}, lastEvent: null, seen: new Set([pending.id]) })
492
+ }
493
+ const since = waiting?.since ?? new Date(Date.now() - 1_000).toISOString()
494
+ let read: TurnRead
495
+ try {
496
+ read = await readTurn(held, { idleMs: TURN_IDLE_MS })
497
+ } catch (err) {
498
+ const refused = await refusedTurn(err, { conversationId, agentId, since, before: waiting?.before ?? [] })
499
+ if (refused) return refused
500
+ throw err
501
+ }
502
+ const real = known?.live ? { realActions: false, realActionsNote: null } : await conversationRunsRealActions(memory, link, agentId, conversationId)
503
+ return finishTurn({
504
+ conversationId,
505
+ agentId,
506
+ held,
507
+ read,
508
+ since: waiting?.since ?? new Date(Date.now() - 1_000).toISOString(),
509
+ before: waiting?.before ?? [],
510
+ started: Date.now(),
511
+ live: known?.live ?? false,
512
+ ...real,
513
+ answered,
514
+ })
515
+ }
516
+
517
+ return [
518
+ {
519
+ name: 'agent_chat',
520
+ description:
521
+ "Send a message to an Agent on the local runtime and get its reply, the tools it called, the tokens it used and the trace id. Talks to the Agent's draft (the files as last applied) unless live is true. A draft runs Functions and Workflows that only read for real and reports the ones that change something as not run, unless realActions is true. Pass conversationId to continue a conversation (it keeps the draft it started on: after an apply, start a new one); leave it out to start one. When the answer has pendingDecision, the turn waits on the person's card (a tool approval, or a Function's confirmation): decide it with agent_decide, and the turn goes on; typed words never answer a card. When the reply check changed the reply, replyBlocked says which check fired, what it found, what the person got and both drafts. Read the trace with trace_get.",
522
+ inputSchema: {
523
+ type: 'object',
524
+ properties: {
525
+ agent: { type: 'string', description: 'The Agent\'s slug, its folder under agents/.' },
526
+ message: { type: 'string', minLength: 1, maxLength: 8000, description: 'What the person says.' },
527
+ conversationId: { type: 'string', description: 'Continue this conversation, from an earlier agent_chat.' },
528
+ live: { type: 'boolean', description: 'Talk to the published version instead of the draft (default false).' },
529
+ realActions: {
530
+ type: 'boolean',
531
+ description:
532
+ "Run the draft's Functions and Workflows that change something for real in this conversation, against the local runtime's records (default false: they are simulated and answer as a success with an empty result; ones that only read always run). Only when starting a conversation, and only for the draft.",
533
+ },
534
+ variables: {
535
+ type: 'object',
536
+ additionalProperties: { type: ['string', 'number', 'boolean'] },
537
+ description: "The conversation's variables, by name ({ customer_id: 'CST-00060' }), as a channel would set them: what the instructions read as {{customer_id}} and what operating.json dataScopes narrow the Agent's reads by. Only when starting a conversation on the draft.",
538
+ },
539
+ },
540
+ required: ['agent', 'message'],
541
+ additionalProperties: false,
542
+ },
543
+ annotations: { title: 'Chat with an Agent', readOnlyHint: false, openWorldHint: false },
544
+ async run(input) {
545
+ const link = requireRuntime(project)
546
+ const slug = stringArg(input, 'agent', { required: true })
547
+ const message = stringArg(input, 'message', { required: true })
548
+ let live = input.live === true
549
+ let realActions = input.realActions === true
550
+ let conversationId = stringArg(input, 'conversationId')
551
+ if (realActions && conversationId) return errorResult('realActions is chosen when a conversation starts: leave out conversationId to start one that runs actions for real.')
552
+ if (realActions && live) return errorResult('realActions is for the draft: the published version always runs its actions for real.')
553
+ const variables = input.variables && typeof input.variables === 'object' && !Array.isArray(input.variables) && Object.keys(input.variables).length ? (input.variables as Record<string, unknown>) : undefined
554
+ if (variables && conversationId) return errorResult('variables are set when a conversation starts: leave out conversationId to start one with them.')
555
+ if (variables && live) return errorResult('variables are for the draft here: the published version gets them from its channel.')
556
+
557
+ let target: { agentId: string; agentName: string }
558
+ let realActionsNote: string | null = null
559
+ const known = conversationId ? memory.conversations.get(conversationId) : undefined
560
+ if (conversationId) {
561
+ // A follow-up: the conversation's own agent (its draft's test copy as it started), never a new copy.
562
+ const stored = await storedConversation(link, conversationId)
563
+ if (!stored) return errorResult(goneConversation(conversationId))
564
+ const remembered = memory.waiting.get(conversationId)?.pending
565
+ const waiting = remembered ? [remembered] : await pendingOnRuntime(link, conversationId)
566
+ if (waiting.length) return decideFirst(conversationId, waiting)
567
+ const agentId = known?.agentId ?? (await chatTarget({ serviceUrl: link.serviceUrl, creds: link.creds }, slug, true)).agentId
568
+ target = { agentId, agentName: stored.agentName ?? known?.agentName ?? (await chatTarget({ serviceUrl: link.serviceUrl, creds: link.creds }, slug, live)).agentName }
569
+ // A follow-up keeps what the conversation started with: the version it talks to, and whether its actions run for real.
570
+ if (known) live = known.live
571
+ if (!live) ({ realActions, realActionsNote } = await conversationRunsRealActions(memory, link, agentId, conversationId))
572
+ } else {
573
+ target = await chatTarget({ serviceUrl: link.serviceUrl, creds: link.creds }, slug, live)
574
+ const session = await serviceCall<{ id: string }>(link, '/v1/sessions', { body: { agentName: target.agentName, workspaceId: link.creds.workspaceId } })
575
+ conversationId = session.id
576
+ if (realActions || variables) {
577
+ // The Try setup Studio's "Run actions for real" sends, before the first message, to the Agent itself (not its
578
+ // draft copy): ONE call (the runtime refuses a second), with the variables, and `simulateActions` said either way.
579
+ try {
580
+ await serviceCall(link, `/v1/config/agents/${encodeURIComponent(target.agentId)}/try/conversations/${encodeURIComponent(conversationId)}`, {
581
+ method: 'PUT',
582
+ body: { ...(variables ? { variables } : {}), simulateActions: !realActions },
583
+ })
584
+ } catch (err) {
585
+ const reason = (err as Error).message
586
+ if (!/answered 400/.test(reason)) throw new Error(`Could not set up this conversation${realActions ? ' to run actions for real' : ''}: ${reason}`)
587
+ const why = reason.replace(/^.*answered 400:\s*/, '').slice(0, 300)
588
+ // The variables are the case's to fix: refused, the turn is not sent with none.
589
+ if (variables) return errorResult(`The conversation's variables were refused: ${why}`)
590
+ realActionsNote = `Real actions could not be turned on (${why}). Functions and Workflows that only read run for real; the ones that change something stay not run in this test.`
591
+ }
592
+ }
593
+ }
594
+ const remembered = memory.conversations.get(conversationId) ?? { agent: slug, agentId: target.agentId, agentName: target.agentName, live, transcript: [], realActions: realActions && !realActionsNote, realActionsNote, ...(variables ? { variables } : {}) }
595
+ memory.conversations.set(conversationId, remembered)
596
+ remembered.transcript.push({ role: 'user', parts: [{ type: 'text', text: message }] })
597
+ // The traces already there, so this turn's is the one that is new.
598
+ const since = new Date(Date.now() - 1_000).toISOString()
599
+ const before = (await conversationTraces(link, conversationId).catch(() => [])).map((t) => t.id)
600
+ const started = Date.now()
601
+ let held: HeldTurn
602
+ try {
603
+ held = await startTurn(link, { conversationId, agentName: target.agentName, message })
604
+ } catch (err) {
605
+ // The runtime refuses a message while a card waits (409 decision_pending): say what to decide.
606
+ const pending = pendingFromConflict((err as Error).message)
607
+ if (!pending) {
608
+ remembered.transcript.pop()
609
+ const refused = await refusedTurn(err, { conversationId, agentId: target.agentId, since, before })
610
+ if (refused) return refused
611
+ throw err
612
+ }
613
+ remembered.transcript.pop()
614
+ return decideFirst(conversationId, pending.length ? pending : await pendingOnRuntime(link, conversationId))
615
+ }
616
+ let read: TurnRead
617
+ try {
618
+ read = await readTurn(held)
619
+ } catch (err) {
620
+ const refused = await refusedTurn(err, { conversationId, agentId: target.agentId, since, before })
621
+ if (refused) return refused
622
+ throw err
623
+ }
624
+ return finishTurn({ conversationId, agentId: target.agentId, held, read, since, before, started, live, realActions, realActionsNote })
625
+ },
626
+ },
627
+ {
628
+ name: 'agent_decide',
629
+ description:
630
+ "Decide what an Agent's turn waits on, as the person would on its card: approve or deny a tool approval, or confirm or cancel a Function's confirmation (agent_chat's pendingDecision gives the id). The run picks up where it parked, and the answer is the rest of the turn like agent_chat's: reply, tool calls, trace and tokens. Each decision is yours to make explicitly; nothing is ever decided for you.",
631
+ inputSchema: {
632
+ type: 'object',
633
+ properties: {
634
+ conversationId: { type: 'string', description: 'The conversation, from agent_chat.' },
635
+ id: { type: 'string', description: 'The decision\'s id: pendingDecision.id.' },
636
+ decision: { type: 'string', enum: [...DECISIONS], description: 'approve or deny for a tool approval; confirm or cancel for a confirmation.' },
637
+ },
638
+ required: ['conversationId', 'id', 'decision'],
639
+ additionalProperties: false,
640
+ },
641
+ annotations: { title: 'Decide an approval or confirmation', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
642
+ async run(input) {
643
+ const conversationId = stringArg(input, 'conversationId', { required: true })
644
+ const id = stringArg(input, 'id', { required: true })
645
+ const decision = stringArg(input, 'decision', { required: true }) as Decision
646
+ if (!DECISIONS.includes(decision)) return errorResult('decision is approve, deny, confirm or cancel.')
647
+ return decide(conversationId, id, decision)
648
+ },
649
+ },
650
+ {
651
+ name: 'trace_get',
652
+ description: 'Read one trace: the span tree, every tool call with its input and output, tokens, latency and errors; toolsOffered: the tools each model call was offered (what the Agent could have called); dataScopes: the data scopes its Blueprint reads ran under; agentVersion/draftOf when a draft ran; and replyChecks: each time the reply check changed a reply (the check, what it found, what the person got, both drafts). Set detail to false for the tree without inputs and outputs.',
653
+ inputSchema: {
654
+ type: 'object',
655
+ properties: {
656
+ traceId: { type: 'string', description: 'From agent_chat or trace_list.' },
657
+ detail: { type: 'boolean', description: 'Include each span\'s input and output, clipped (default true).' },
658
+ },
659
+ required: ['traceId'],
660
+ additionalProperties: false,
661
+ },
662
+ annotations: { title: 'Read a trace', readOnlyHint: true, openWorldHint: false },
663
+ async run(input) {
664
+ const link = requireRuntime(project)
665
+ const id = stringArg(input, 'traceId', { required: true })
666
+ const trace = await getTrace(link, id)
667
+ if (!trace) return errorResult(`no trace "${id}" on the local runtime; trace_list shows the recent ones`)
668
+ return jsonResult(readTrace(link, trace, { detail: input.detail !== false }))
669
+ },
670
+ },
671
+ {
672
+ name: 'trace_list',
673
+ description: 'Recent traces on the local runtime, newest first: Agent turns, workflow runs and Function calls. Filter by conversation, name or errors.',
674
+ inputSchema: {
675
+ type: 'object',
676
+ properties: {
677
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'At most this many (default 10).' },
678
+ conversationId: { type: 'string', description: 'Only this conversation\'s turns.' },
679
+ name: { type: 'string', description: 'Only traces whose name contains this, e.g. an Agent\'s slug.' },
680
+ errorsOnly: { type: 'boolean', description: 'Only traces with an error.' },
681
+ sinceMinutes: { type: 'integer', minimum: 1, maximum: 10080, description: 'Only the last this many minutes.' },
682
+ },
683
+ additionalProperties: false,
684
+ },
685
+ annotations: { title: 'List traces', readOnlyHint: true, openWorldHint: false },
686
+ async run(input) {
687
+ const link = requireRuntime(project)
688
+ const minutes = input.sinceMinutes === undefined ? null : intArg(input, 'sinceMinutes', 60, 1, 10080)
689
+ const traces = await listTraces(link, {
690
+ limit: intArg(input, 'limit', 10, 1, 50),
691
+ conversationId: stringArg(input, 'conversationId'),
692
+ name: stringArg(input, 'name'),
693
+ errorsOnly: input.errorsOnly === true,
694
+ since: minutes === null ? undefined : new Date(Date.now() - minutes * 60_000).toISOString(),
695
+ })
696
+ return jsonResult({ traces, link: studioLink(link, '/traces') })
697
+ },
698
+ },
699
+ ]
700
+ }