@frontera-sdk/cli 1.52.39 → 1.52.41
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -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
|
+
}
|