@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
|
@@ -3,6 +3,13 @@ import { join, relative } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { parse } from 'yaml'
|
|
5
5
|
|
|
6
|
+
import { contentHash, loadBuild } from './local-applied'
|
|
7
|
+
import { caseCouldNotRun, stopsTheRun, withDirectModel, type CaseNotRun } from './local-case-not-run'
|
|
8
|
+
import { directModelFor } from './local-seed'
|
|
9
|
+
import { allTargets } from './local-dev-sync'
|
|
10
|
+
import type { LocalProject } from './local-project'
|
|
11
|
+
import { checkAgainstSchema, describeProblems } from './local-schema-check'
|
|
12
|
+
import { cliSchema, MAX_TURN_WAIT_SECONDS } from './local-schemas'
|
|
6
13
|
import type { LocalCredentials } from './local-state'
|
|
7
14
|
|
|
8
15
|
/**
|
|
@@ -25,11 +32,31 @@ import type { LocalCredentials } from './local-state'
|
|
|
25
32
|
export type TestCheck =
|
|
26
33
|
| { type: 'contains'; value: string; text?: string }
|
|
27
34
|
| { type: 'regex'; value: string; flags?: string; text?: string }
|
|
28
|
-
| {
|
|
35
|
+
| {
|
|
36
|
+
type: 'tool_called' | 'tool_not_called'
|
|
37
|
+
tool: string
|
|
38
|
+
argsSubset?: Record<string, unknown>
|
|
39
|
+
mode?: 'exact' | 'ignore' | 'subset' | 'superset'
|
|
40
|
+
text?: string
|
|
41
|
+
/** The calls of every turn (`all`, the default) or only of the last one, whose reply the case grades. */
|
|
42
|
+
turn?: 'all' | 'last'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One turn of a case's conversation: a person's message (sent), or an
|
|
47
|
+
* assistant's (kept for whoever reads the case). `waitSeconds` on a person's
|
|
48
|
+
* turn waits that long before sending it, so a case can keep a gap between
|
|
49
|
+
* turns ("turn 2 a minute after turn 1").
|
|
50
|
+
*/
|
|
51
|
+
export interface CaseTurn {
|
|
52
|
+
user?: string
|
|
53
|
+
assistant?: string
|
|
54
|
+
waitSeconds?: number
|
|
55
|
+
}
|
|
29
56
|
|
|
30
57
|
export interface TestCase {
|
|
31
58
|
name: string
|
|
32
|
-
conversation:
|
|
59
|
+
conversation: CaseTurn[]
|
|
33
60
|
expected?: string
|
|
34
61
|
/** Pass when the reply contains each of these, instead of matching exactly. */
|
|
35
62
|
expectedContains?: string[]
|
|
@@ -39,10 +66,23 @@ export interface TestCase {
|
|
|
39
66
|
judged?: string[]
|
|
40
67
|
/** Human-certified on the platform. */
|
|
41
68
|
gold?: boolean
|
|
69
|
+
/** Guards the Agent in change requests: run, against the last applied run, before a request applies (design 2026-10-18 §6). */
|
|
70
|
+
gate?: boolean
|
|
71
|
+
/** A turn that stops on a tool approval: approve or deny it and read on, or `expect` it (the case passes by waiting). */
|
|
72
|
+
decision?: CaseDecision
|
|
73
|
+
/** Run the tools that change something for real (a decision implies it); simulated otherwise. */
|
|
74
|
+
realActions?: boolean
|
|
75
|
+
/** The conversation's variables (`customer_id: CST-00060`), set before its first message. */
|
|
76
|
+
variables?: Record<string, string | number | boolean>
|
|
42
77
|
/** A conversation with a simulated customer, run by the local runtime's evaluation runner. */
|
|
43
78
|
scenario?: Record<string, unknown>
|
|
44
79
|
/** What the scenario expects: its outcome, the actions that must (or must not) run. */
|
|
45
80
|
expect?: Record<string, unknown>
|
|
81
|
+
/**
|
|
82
|
+
* Where the case breaks the evals format (`frontera local schema evals`), by
|
|
83
|
+
* file and path; such a case fails with these and is not sent to the Agent.
|
|
84
|
+
*/
|
|
85
|
+
problems?: string[]
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
/** A tool the Agent called during the case, from the chat stream. */
|
|
@@ -51,6 +91,13 @@ export interface ToolCall {
|
|
|
51
91
|
args: unknown
|
|
52
92
|
/** It ran to completion. */
|
|
53
93
|
ok: boolean
|
|
94
|
+
/**
|
|
95
|
+
* It waits on a person's approval (the turn parked on it, and nobody has
|
|
96
|
+
* decided it): the Agent called it, so `tool_called` counts it, with the
|
|
97
|
+
* arguments the approval shows. A call approved and run is `ok`; one denied
|
|
98
|
+
* is neither.
|
|
99
|
+
*/
|
|
100
|
+
parked?: boolean
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
export interface CaseResult {
|
|
@@ -64,16 +111,72 @@ export interface CaseResult {
|
|
|
64
111
|
/** The Agent's config id and the conversation the case ran in, so Studio can open it. */
|
|
65
112
|
agentId?: string
|
|
66
113
|
conversationId?: string
|
|
114
|
+
/** When the case was sent, so its traces can be found by their time (`local-case-evidence.ts`). */
|
|
115
|
+
startedAt?: string
|
|
116
|
+
/** How many tools the Agent called, for efficiency against a baseline. */
|
|
117
|
+
toolCalls?: number
|
|
118
|
+
/**
|
|
119
|
+
* The waits the case kept between turns (`waitSeconds`), in seconds, in
|
|
120
|
+
* order: said in the run's output, since such a case takes that long.
|
|
121
|
+
*/
|
|
122
|
+
waited?: Array<{ turn: number; seconds: number }>
|
|
123
|
+
/**
|
|
124
|
+
* The model provider refused the first try for its limits (a 402 or 429
|
|
125
|
+
* about credits, rate or in-flight requests), so the case was run once more
|
|
126
|
+
* after a pause: what it said the first time.
|
|
127
|
+
*/
|
|
128
|
+
retried?: string
|
|
129
|
+
/**
|
|
130
|
+
* The case couldn't run: the model provider refused it (credits, a rate
|
|
131
|
+
* limit, a key), the turn timed out, or the local runtime did not answer.
|
|
132
|
+
* It is not passed, and it is not a failure of the Agent: never newly
|
|
133
|
+
* failing, and never recorded as the case's last outcome.
|
|
134
|
+
*/
|
|
135
|
+
notRun?: CaseNotRun
|
|
67
136
|
}
|
|
68
137
|
|
|
69
138
|
export interface CaseFile {
|
|
70
139
|
agent: string
|
|
71
140
|
file: string
|
|
72
141
|
cases: TestCase[]
|
|
142
|
+
/**
|
|
143
|
+
* Why the file as a whole cannot run (it is not YAML, or has no `cases` list),
|
|
144
|
+
* said as one failed result rather than thrown, so one bad file fails only itself.
|
|
145
|
+
*/
|
|
146
|
+
problem?: string
|
|
73
147
|
/** The Agent's regression dataset in the workspace: `local test --regression` runs only these. */
|
|
74
148
|
regression?: boolean
|
|
75
149
|
}
|
|
76
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Only the cases marked `gate: true`, file by file; files left with none are
|
|
153
|
+
* dropped. Each case is the one the file gave, every field kept (`variables`,
|
|
154
|
+
* `realActions`, `decision`, `checks`): a gate run sends what any other run
|
|
155
|
+
* sends. A file that can't be read is kept: it may hold gate cases, and it
|
|
156
|
+
* fails with why rather than vanish from the run.
|
|
157
|
+
*/
|
|
158
|
+
export function gatedCaseFiles(files: CaseFile[]): CaseFile[] {
|
|
159
|
+
return files.map((f) => (f.problem ? f : { ...f, cases: f.cases.filter((c) => c.gate === true) })).filter((f) => f.cases.length || f.problem)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The case files a run takes, chosen the same way by every path that runs
|
|
164
|
+
* them (`frontera local test`, `local check`, MCP `test_run`, a change
|
|
165
|
+
* request's evals, Studio): one Agent's, one file, the regression datasets,
|
|
166
|
+
* the gate cases. The cases themselves are always as `readCaseFile` read
|
|
167
|
+
* them, so no path drops a field another sends.
|
|
168
|
+
*/
|
|
169
|
+
export function selectCaseFiles(root: string, scope: { agent?: string; file?: string; regression?: boolean; gate?: boolean } = {}): CaseFile[] {
|
|
170
|
+
const file = scope.file?.replace(/^\.\//, '')
|
|
171
|
+
const files = findCaseFiles(root, scope.agent)
|
|
172
|
+
.filter((f) => !file || f.file === file)
|
|
173
|
+
.filter((f) => !scope.regression || f.regression)
|
|
174
|
+
return scope.gate ? gatedCaseFiles(files) : files
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** How many results a run of these files gives: each case, or one for a file that can't be read. */
|
|
178
|
+
export const caseCount = (files: CaseFile[]): number => files.reduce((n, f) => n + Math.max(f.cases.length, f.problem ? 1 : 0), 0)
|
|
179
|
+
|
|
77
180
|
export function findCaseFiles(root: string, agent?: string): CaseFile[] {
|
|
78
181
|
const agentsDir = join(root, 'agents')
|
|
79
182
|
if (!existsSync(agentsDir)) return []
|
|
@@ -82,25 +185,62 @@ export function findCaseFiles(root: string, agent?: string): CaseFile[] {
|
|
|
82
185
|
if (agent && slug !== agent) continue
|
|
83
186
|
const evalsDir = join(agentsDir, slug, 'evals')
|
|
84
187
|
if (!existsSync(evalsDir)) continue
|
|
85
|
-
for (const name of readdirSync(evalsDir)) {
|
|
86
|
-
if (!/\.ya?ml$/.test(name)) continue
|
|
188
|
+
for (const name of readdirSync(evalsDir).sort()) {
|
|
189
|
+
if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
|
|
87
190
|
const path = join(evalsDir, name)
|
|
88
|
-
|
|
89
|
-
out.push({ agent: slug, file: relative(root, path), cases: doc?.cases ?? [], ...(doc?.regression === true ? { regression: true } : {}) })
|
|
191
|
+
out.push(readCaseFile(slug, relative(root, path), readFileSync(path, 'utf8')))
|
|
90
192
|
}
|
|
91
193
|
}
|
|
92
194
|
return out
|
|
93
195
|
}
|
|
94
196
|
|
|
197
|
+
/**
|
|
198
|
+
* One evals file, checked against its format before anything runs: a problem
|
|
199
|
+
* with the file as a whole is the file's `problem`; a case that breaks the
|
|
200
|
+
* format carries its `problems` (`agents/x/evals/y.yaml: cases.2.checks.0.value:
|
|
201
|
+
* must be text`) and fails with them instead of crashing the run.
|
|
202
|
+
*/
|
|
203
|
+
export function readCaseFile(agent: string, file: string, text: string): CaseFile {
|
|
204
|
+
let doc: unknown
|
|
205
|
+
try {
|
|
206
|
+
doc = parse(text)
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return { agent, file, cases: [], problem: `${file}: the file is not YAML: ${(err as Error).message.split('\n')[0]}` }
|
|
209
|
+
}
|
|
210
|
+
const raw = (doc ?? {}) as { cases?: unknown; regression?: unknown }
|
|
211
|
+
const regression = raw.regression === true ? { regression: true } : {}
|
|
212
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !Array.isArray(raw.cases)) {
|
|
213
|
+
return { agent, file, cases: [], problem: `${file}: the file needs a \`cases:\` list (\`frontera local schema evals\`)`, ...regression }
|
|
214
|
+
}
|
|
215
|
+
const problems = checkAgainstSchema(doc, cliSchema('evals')!)
|
|
216
|
+
const byCase = new Map<number, string[]>()
|
|
217
|
+
const fileWide: string[] = []
|
|
218
|
+
for (const p of problems) {
|
|
219
|
+
const m = /^cases\.(\d+)(\.|$)/.exec(p.path)
|
|
220
|
+
if (m) byCase.set(Number(m[1]), [...(byCase.get(Number(m[1])) ?? []), ...describeProblems([p], file)])
|
|
221
|
+
else fileWide.push(...describeProblems([p], file))
|
|
222
|
+
}
|
|
223
|
+
const cases = (raw.cases as unknown[]).map((c, i) => {
|
|
224
|
+
const o = (c && typeof c === 'object' && !Array.isArray(c) ? c : {}) as TestCase
|
|
225
|
+
const name = typeof o.name === 'string' && o.name ? o.name : `case ${i + 1}`
|
|
226
|
+
const found = [...fileWide, ...(byCase.get(i) ?? [])]
|
|
227
|
+
// The evaluation runner that runs a scenario gives it no variables yet: said, not silently dropped.
|
|
228
|
+
if (o.scenario && o.variables && Object.keys(o.variables).length) found.push(`${file}: cases.${i} (${name}): a scenario case can’t set \`variables\` yet (the evaluation runner gives a scenario none); give the variables to a case with \`conversation\` instead`)
|
|
229
|
+
return { ...o, name, ...(found.length ? { problems: found } : {}) }
|
|
230
|
+
})
|
|
231
|
+
return { agent, file, cases, ...regression }
|
|
232
|
+
}
|
|
233
|
+
|
|
95
234
|
function normalise(text: string): string {
|
|
96
235
|
return text.replace(/\s+/g, ' ').trim()
|
|
97
236
|
}
|
|
98
237
|
|
|
99
|
-
|
|
238
|
+
/** `lastTurnCalls`: the last turn's calls, for a check scoped `turn: last`; every call when not given. */
|
|
239
|
+
export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = [], lastTurnCalls?: ToolCall[]): { passed: boolean; reason?: string } {
|
|
100
240
|
const answer = judgeAnswer(c, reply)
|
|
101
241
|
if (!answer.passed) return answer
|
|
102
242
|
for (const check of c.checks ?? []) {
|
|
103
|
-
const failed = runCheck(check, reply, toolCalls)
|
|
243
|
+
const failed = runCheck(check, reply, toolCalls, lastTurnCalls)
|
|
104
244
|
if (failed) return { passed: false, reason: failed }
|
|
105
245
|
}
|
|
106
246
|
return { passed: true }
|
|
@@ -129,14 +269,23 @@ function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall):
|
|
|
129
269
|
const mode = check.mode ?? 'superset'
|
|
130
270
|
if (mode === 'ignore' || !check.argsSubset) return true
|
|
131
271
|
if (!isRecord(call.args)) return false
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
272
|
+
// Every tool takes an `action` narration ("Running intake…") and strips it: not an argument the case means.
|
|
273
|
+
const { action: _narration, ...rest } = call.args
|
|
274
|
+
const args = 'action' in check.argsSubset ? call.args : rest
|
|
275
|
+
if (mode === 'superset') return containsAll(args, check.argsSubset)
|
|
276
|
+
if (mode === 'subset') return containsAll(check.argsSubset, args)
|
|
277
|
+
return containsAll(args, check.argsSubset) && Object.keys(args).length === Object.keys(check.argsSubset).length
|
|
135
278
|
}
|
|
136
279
|
|
|
137
280
|
/** Why a check failed, or null when it passed. */
|
|
138
|
-
export function runCheck(check: TestCheck, reply: string,
|
|
281
|
+
export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[]): string | null {
|
|
139
282
|
const label = check.text ? ` (${check.text})` : ''
|
|
283
|
+
const lastOnly = 'turn' in check && check.turn === 'last'
|
|
284
|
+
const toolCalls = lastOnly && lastTurnCalls ? lastTurnCalls : allCalls
|
|
285
|
+
// The format says text; a number or a missing value is refused, not thrown on.
|
|
286
|
+
if ((check.type === 'contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) {
|
|
287
|
+
return `the ${check.type} check's \`value\` must be text (it is ${check.value === undefined ? 'missing' : JSON.stringify(check.value)}); quote it in the file${label}`
|
|
288
|
+
}
|
|
140
289
|
if (check.type === 'contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? null : `reply does not mention: ${check.value}${label}`
|
|
141
290
|
if (check.type === 'regex') {
|
|
142
291
|
if (check.value.length > 500 || NESTED_QUANTIFIER.test(check.value)) return `the pattern ${check.value} can't run safely${label}`
|
|
@@ -148,12 +297,14 @@ export function runCheck(check: TestCheck, reply: string, toolCalls: ToolCall[])
|
|
|
148
297
|
}
|
|
149
298
|
return pattern.test(reply.slice(0, 20_000)) ? null : `nothing in the reply matches /${check.value}/${label}`
|
|
150
299
|
}
|
|
151
|
-
// A call that ran proves a tool was called; any attempt counts against one that must not be.
|
|
152
|
-
const candidates = check.type === 'tool_called' ? toolCalls.filter((c) => c.ok) : toolCalls
|
|
300
|
+
// A call that ran, or one waiting on a person's approval, proves a tool was called; any attempt counts against one that must not be.
|
|
301
|
+
const candidates = check.type === 'tool_called' ? toolCalls.filter((c) => c.ok || c.parked) : toolCalls
|
|
153
302
|
const matched = candidates.some((c) => c.name === check.tool && argsMatch(check, c))
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
303
|
+
const said = (c: ToolCall) => `${c.name}${c.parked ? ' (waiting on approval)' : !c.ok ? ' (did not run)' : ''}`
|
|
304
|
+
const made = toolCalls.length ? `calls made: ${[...new Set(toolCalls.map(said))].join(', ')}` : 'no tools were called'
|
|
305
|
+
const where = lastOnly ? ' in the last turn' : ''
|
|
306
|
+
if (check.type === 'tool_called') return matched ? null : `no matching call to ${check.tool}${where}; ${made}${label}`
|
|
307
|
+
return matched ? `${check.tool} was called${where}, which the case does not allow${label}` : null
|
|
157
308
|
}
|
|
158
309
|
|
|
159
310
|
export interface Api {
|
|
@@ -161,7 +312,7 @@ export interface Api {
|
|
|
161
312
|
creds: LocalCredentials
|
|
162
313
|
}
|
|
163
314
|
|
|
164
|
-
async function call<T>(api: Api, path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
315
|
+
export async function call<T>(api: Api, path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
165
316
|
const res = await fetch(new URL(path, api.serviceUrl), {
|
|
166
317
|
method: init.method ?? (init.body === undefined ? 'GET' : 'POST'),
|
|
167
318
|
headers: {
|
|
@@ -182,50 +333,264 @@ async function call<T>(api: Api, path: string, init: { method?: string; body?: u
|
|
|
182
333
|
const TURN_TIMEOUT_MS = 180_000
|
|
183
334
|
|
|
184
335
|
/** Send one person turn and collect the streamed reply's text and the tools it called. */
|
|
185
|
-
|
|
336
|
+
/** A turn that stopped on a decision a person makes: a tool waiting for approval, or a confirmation. */
|
|
337
|
+
export interface PendingDecision {
|
|
338
|
+
/** The tool that waits, when the stream named it. */
|
|
339
|
+
tool: string
|
|
340
|
+
/** Present for a tool approval: what `POST /v1/chat/approvals/:approvalId` decides. */
|
|
341
|
+
approvalId?: string
|
|
342
|
+
/** The waiting call, when the stream gave its id. */
|
|
343
|
+
toolCallId?: string
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The call a `tool-approval-request` parks, marked as waiting: the call the
|
|
348
|
+
* stream announced under its id, or, when the request is the first the
|
|
349
|
+
* stream says of it, one made from the request's own tool name and
|
|
350
|
+
* arguments (`toolName`, `input` or `args`). Null when it names no tool.
|
|
351
|
+
*/
|
|
352
|
+
export function parkCall(calls: Map<string, ToolCall>, event: { toolCallId?: string; toolName?: string; input?: unknown; args?: unknown; approvalId?: string }): (ToolCall & { id: string }) | null {
|
|
353
|
+
const id = event.toolCallId ?? (event.approvalId ? `approval:${event.approvalId}` : undefined)
|
|
354
|
+
if (!id) return null
|
|
355
|
+
let found = calls.get(id) as (ToolCall & { id?: string }) | undefined
|
|
356
|
+
if (!found) {
|
|
357
|
+
if (!event.toolName) return null
|
|
358
|
+
found = { id, name: event.toolName, args: event.input ?? event.args, ok: false }
|
|
359
|
+
calls.set(id, found)
|
|
360
|
+
} else if (found.args === undefined && (event.input ?? event.args) !== undefined) found.args = event.input ?? event.args
|
|
361
|
+
if (!found.ok) found.parked = true
|
|
362
|
+
return { ...found, id }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** How a case answers a decision its turns stop on (`decision` in the evals format). */
|
|
366
|
+
export type CaseDecision = 'approve' | 'deny' | 'expect'
|
|
367
|
+
|
|
368
|
+
const chatHeaders = (api: Api) => ({
|
|
369
|
+
authorization: `Bearer ${api.creds.session}`,
|
|
370
|
+
'x-org-id': api.creds.organizationId,
|
|
371
|
+
'x-workspace-id': api.creds.workspaceId,
|
|
372
|
+
'content-type': 'application/json',
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Read a turn's stream into the reply and the tools called. Stops at a
|
|
377
|
+
* decision a person makes (a tool approval, a confirmation): the stream stays
|
|
378
|
+
* open while the card waits, so reading on would hang the case until it timed
|
|
379
|
+
* out. Counts the events read, so a resumed stream starts after them.
|
|
380
|
+
*/
|
|
381
|
+
async function readTurn(body: ReadableStream<Uint8Array>, into: { reply: string; calls: Map<string, ToolCall>; events: number; seen: Set<string> }): Promise<PendingDecision | null> {
|
|
382
|
+
let buffer = ''
|
|
383
|
+
const decoder = new TextDecoder()
|
|
384
|
+
const reader = body.getReader()
|
|
385
|
+
try {
|
|
386
|
+
for (;;) {
|
|
387
|
+
const { value, done } = await reader.read()
|
|
388
|
+
if (done) return null
|
|
389
|
+
buffer += decoder.decode(value, { stream: true })
|
|
390
|
+
let newline: number
|
|
391
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
392
|
+
const line = buffer.slice(0, newline).trim()
|
|
393
|
+
buffer = buffer.slice(newline + 1)
|
|
394
|
+
if (!line.startsWith('data:')) continue
|
|
395
|
+
const payload = line.slice(5).trim()
|
|
396
|
+
if (payload === '[DONE]') continue
|
|
397
|
+
let event: { type?: string; delta?: string; errorText?: string; toolCallId?: string; toolName?: string; input?: unknown; args?: unknown; approvalId?: string; data?: Record<string, unknown> }
|
|
398
|
+
try {
|
|
399
|
+
event = JSON.parse(payload)
|
|
400
|
+
} catch {
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
into.events += 1
|
|
404
|
+
if (event.type === 'text-delta' && typeof event.delta === 'string') into.reply += event.delta
|
|
405
|
+
if (event.type === 'tool-input-available' && event.toolCallId && event.toolName) into.calls.set(event.toolCallId, { name: event.toolName, args: event.input, ok: false })
|
|
406
|
+
if (event.type === 'tool-output-available' && event.toolCallId && into.calls.has(event.toolCallId)) Object.assign(into.calls.get(event.toolCallId)!, { ok: true, parked: false })
|
|
407
|
+
if ((event.type === 'tool-output-denied' || event.type === 'tool-output-error') && event.toolCallId && into.calls.has(event.toolCallId)) into.calls.get(event.toolCallId)!.parked = false
|
|
408
|
+
if (event.type === 'error') throw new Error(event.errorText ?? 'the turn failed')
|
|
409
|
+
// The turn is over: what follows is keepalives until the socket closes.
|
|
410
|
+
if (event.type === 'finish') return null
|
|
411
|
+
// Each decision once: a stream read on from an earlier point may say it again.
|
|
412
|
+
if (event.type === 'tool-approval-request' && !(event.approvalId && into.seen.has(event.approvalId))) {
|
|
413
|
+
if (event.approvalId) into.seen.add(event.approvalId)
|
|
414
|
+
const parked = parkCall(into.calls, event)
|
|
415
|
+
const tool = parked?.name || event.toolName || 'a tool'
|
|
416
|
+
return { tool, ...(typeof event.approvalId === 'string' ? { approvalId: event.approvalId } : {}), ...(parked?.id ? { toolCallId: parked.id } : {}) }
|
|
417
|
+
}
|
|
418
|
+
const card = event.type === 'data-person-confirmation' ? event.data : undefined
|
|
419
|
+
if (card && card.status === 'pending' && typeof card.id === 'string' && !into.seen.has(card.id)) {
|
|
420
|
+
into.seen.add(card.id)
|
|
421
|
+
return { tool: typeof card.summary === 'string' && card.summary ? `a confirmation (${card.summary})` : 'a confirmation' }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} finally {
|
|
426
|
+
// A stream parked on a decision is let go of, not drained.
|
|
427
|
+
await reader.cancel().catch(() => {})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One user turn: the reply, the tools called, and the decision it stopped on
|
|
433
|
+
* when it waits for a person. With `decide` (approve or deny) a tool approval
|
|
434
|
+
* is answered (`POST /v1/chat/approvals/:approvalId`) and the run's stream
|
|
435
|
+
* read on from where it stopped (`GET /v1/chat/:runId/stream?startIndex=N`),
|
|
436
|
+
* at most five times in a turn.
|
|
437
|
+
*/
|
|
438
|
+
export async function sendTurn(api: Api, conversationId: string, agentName: string, text: string, decide?: 'approve' | 'deny'): Promise<{ reply: string; toolCalls: ToolCall[]; pending?: PendingDecision }> {
|
|
439
|
+
const signal = AbortSignal.timeout(TURN_TIMEOUT_MS)
|
|
186
440
|
const res = await fetch(new URL('/v1/chat', api.serviceUrl), {
|
|
187
441
|
method: 'POST',
|
|
188
|
-
headers:
|
|
189
|
-
authorization: `Bearer ${api.creds.session}`,
|
|
190
|
-
'x-org-id': api.creds.organizationId,
|
|
191
|
-
'x-workspace-id': api.creds.workspaceId,
|
|
192
|
-
'content-type': 'application/json',
|
|
193
|
-
},
|
|
442
|
+
headers: chatHeaders(api),
|
|
194
443
|
body: JSON.stringify({
|
|
195
444
|
conversationId,
|
|
196
445
|
agentName,
|
|
197
446
|
messages: [{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }],
|
|
198
447
|
}),
|
|
199
448
|
// A reply that never finishes (the stream's keepalives hold it open) fails the case, not the whole run.
|
|
200
|
-
signal
|
|
449
|
+
signal,
|
|
201
450
|
})
|
|
202
|
-
if (!res.ok || !res.body)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
451
|
+
if (!res.ok || !res.body) {
|
|
452
|
+
const body = await res.text()
|
|
453
|
+
// The conversation still waits on a decision from an earlier turn.
|
|
454
|
+
if (res.status === 409 && /decision_pending/.test(body)) throw new Error('the conversation waits for a decision from an earlier turn; give the case `decision: approve` or `deny`')
|
|
455
|
+
throw new Error(`chat answered ${res.status}: ${body.slice(0, 200)}`)
|
|
456
|
+
}
|
|
457
|
+
const runId = res.headers.get('x-workflow-run-id')
|
|
458
|
+
const into = { reply: '', calls: new Map<string, ToolCall>(), events: 0, seen: new Set<string>() }
|
|
459
|
+
let pending = await readTurn(res.body, into)
|
|
460
|
+
for (let n = 0; pending && decide && pending.approvalId && runId && n < 5; n++) {
|
|
461
|
+
const decided = await fetch(new URL(`/v1/chat/approvals/${encodeURIComponent(pending.approvalId)}`, api.serviceUrl), { method: 'POST', headers: chatHeaders(api), body: JSON.stringify({ decision: decide }), signal })
|
|
462
|
+
if (!decided.ok) throw new Error(`deciding ${pending.tool} answered ${decided.status}: ${(await decided.text()).slice(0, 200)}`)
|
|
463
|
+
// Decided: no longer waiting. Approved, it runs (and its output marks it done); denied, it never runs.
|
|
464
|
+
const waiting = pending.toolCallId ? into.calls.get(pending.toolCallId) : undefined
|
|
465
|
+
if (waiting) waiting.parked = false
|
|
466
|
+
const resumed = await fetch(new URL(`/v1/chat/${encodeURIComponent(runId)}/stream?startIndex=${into.events}`, api.serviceUrl), { headers: chatHeaders(api), signal })
|
|
467
|
+
if (!resumed.ok || !resumed.body) throw new Error(`reading on after ${pending.tool} answered ${resumed.status}`)
|
|
468
|
+
pending = await readTurn(resumed.body, into)
|
|
469
|
+
}
|
|
470
|
+
return { reply: into.reply, toolCalls: [...into.calls.values()], ...(pending ? { pending } : {}) }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Whether a case runs real actions: it says so, or it answers a decision, which
|
|
475
|
+
* only a real action raises (a simulated one answers an empty success).
|
|
476
|
+
*/
|
|
477
|
+
export function usesRealActions(c: Pick<TestCase, 'realActions' | 'decision'>): boolean {
|
|
478
|
+
return c.realActions === true || c.decision === 'approve' || c.decision === 'deny' || c.decision === 'expect'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Switch a test conversation to real actions before its first message, the
|
|
483
|
+
* call `agent_chat` with realActions makes. `agentId` is the Agent's own id,
|
|
484
|
+
* not its preview copy's. Sent only for such a case: the call without
|
|
485
|
+
* `simulateActions` means real actions.
|
|
486
|
+
*/
|
|
487
|
+
export async function useRealActions(api: Api, agentId: string, conversationId: string, slug: string, fetcher: typeof fetch = fetch): Promise<void> {
|
|
488
|
+
return setUpConversation(api, agentId, conversationId, slug, { realActions: true }, fetcher)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Set a test conversation up before its first message, in ONE call (the
|
|
493
|
+
* runtime refuses a second): real actions or simulated, and the case's
|
|
494
|
+
* variables. Leaving `simulateActions` out means real actions, so a case
|
|
495
|
+
* with variables and simulated actions says `simulateActions: true`. Nothing
|
|
496
|
+
* is sent for a simulated case without variables.
|
|
497
|
+
*/
|
|
498
|
+
export async function setUpConversation(
|
|
499
|
+
api: Api,
|
|
500
|
+
agentId: string,
|
|
501
|
+
conversationId: string,
|
|
502
|
+
slug: string,
|
|
503
|
+
setup: { realActions: boolean; variables?: Record<string, unknown> },
|
|
504
|
+
fetcher: typeof fetch = fetch,
|
|
505
|
+
): Promise<void> {
|
|
506
|
+
const variables = setup.variables && Object.keys(setup.variables).length ? setup.variables : undefined
|
|
507
|
+
if (!setup.realActions && !variables) return
|
|
508
|
+
const res = await fetcher(new URL(`/v1/config/agents/${encodeURIComponent(agentId)}/try/conversations/${encodeURIComponent(conversationId)}`, api.serviceUrl), {
|
|
509
|
+
method: 'PUT',
|
|
510
|
+
headers: { authorization: `Bearer ${api.creds.session}`, 'x-org-id': api.creds.organizationId, 'x-workspace-id': api.creds.workspaceId, 'content-type': 'application/json' },
|
|
511
|
+
body: JSON.stringify({ ...(variables ? { variables } : {}), simulateActions: !setup.realActions }),
|
|
512
|
+
signal: AbortSignal.timeout(60_000),
|
|
513
|
+
})
|
|
514
|
+
if (res.ok) return
|
|
515
|
+
const text = (await res.text()).replace(/\s+/g, ' ').slice(0, 240)
|
|
516
|
+
const what = [setup.realActions ? 'real actions (it has `decision` or `realActions: true`)' : '', variables ? `the variables ${Object.keys(variables).join(', ')}` : ''].filter(Boolean).join(' and ')
|
|
517
|
+
if (res.status === 400) throw new Error(`this case sets up ${what}, which agents/${slug} refused here: ${text}`)
|
|
518
|
+
throw new Error(`the conversation could not be set up with ${what}: ${res.status} ${text}`)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Why an Agent's draft on the runtime is not what its files say, or null when
|
|
523
|
+
* it is: a file of it (agent.json, operating.json, bindings.json,
|
|
524
|
+
* lessons.yaml) changed since `local dev` applied it, never applied, or did not
|
|
525
|
+
* apply. Read from the build record (`.frontera/local/build.json`).
|
|
526
|
+
*/
|
|
527
|
+
export function agentFilesProblem(project: LocalProject, organizationId: string | null, slug: string): string | null {
|
|
528
|
+
const targets = allTargets(project.root).filter((t) => (t.kind === 'agent' || t.kind === 'operating' || t.kind === 'bindings' || t.kind === 'lessons') && t.slug === slug)
|
|
529
|
+
// An Agent the project has no files for (made in Studio only) is tested as the runtime has it.
|
|
530
|
+
if (!targets.length) return null
|
|
531
|
+
const build = loadBuild(project, organizationId)
|
|
532
|
+
const again = 'run `frontera local dev --once` (or keep `frontera local dev` running), then test again'
|
|
533
|
+
for (const t of targets) {
|
|
534
|
+
const file = (t as { file: string }).file
|
|
535
|
+
const entry = build[t.key]
|
|
536
|
+
if (!entry) return `${file} is not applied to the local runtime yet: ${again}`
|
|
537
|
+
if (entry.status === 'failed' || entry.status === 'blocked') return `${file} did not apply (${entry.status}${entry.error ? `: ${entry.error}` : ''}), so the Agent's draft is not what its files say: fix it, ${again}`
|
|
538
|
+
if (entry.hash !== contentHash(project, t)) return `${file} changed since \`local dev\` applied it, so the Agent's draft is not what its files say: ${again}`
|
|
539
|
+
}
|
|
540
|
+
return null
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The Agent's config id and the name a conversation addresses: its draft's preview, or the live version. */
|
|
544
|
+
export async function chatTarget(api: Api, slug: string, live: boolean): Promise<{ agentId: string; agentName: string }> {
|
|
545
|
+
const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(slug)}`)
|
|
546
|
+
if (live) return { agentId: agent.id, agentName: slug }
|
|
547
|
+
const preview = await call<{ previewAgentSlug: string }>(api, `/v1/config/agents/${agent.id}/test-session`, { body: {} })
|
|
548
|
+
return { agentId: agent.id, agentName: preview.previewAgentSlug }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Agent cases run at once by default: modest, so a provider account's limit on requests in flight holds. */
|
|
552
|
+
export const DEFAULT_CASE_CONCURRENCY = 2
|
|
553
|
+
/** How long a case waits before its one retry after the provider refused it for its limits. */
|
|
554
|
+
export const PROVIDER_RETRY_DELAY_MS = 8_000
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Whether a failure is the model provider refusing for its limits rather than
|
|
558
|
+
* the Agent failing the case: a 402 or 429 about credits, rate, quota or the
|
|
559
|
+
* requests in flight ("This request would exceed your available credits given
|
|
560
|
+
* your current in-flight requests").
|
|
561
|
+
*/
|
|
562
|
+
export function isProviderLimit(message: string): boolean {
|
|
563
|
+
return /\b(402|429)\b/.test(message) && /credit|in-flight|in flight|rate.?limit|too many requests|quota|overloaded/i.test(message)
|
|
564
|
+
|| /\b(rate limit(ed)?|too many requests)\b/i.test(message) && /provider|model|request/i.test(message)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** `--concurrency` as given: a whole number from 1 to 16, else a usage error naming it. Undefined when not given. */
|
|
568
|
+
export function caseConcurrency(raw: string | number | undefined): number | undefined {
|
|
569
|
+
if (raw === undefined || raw === '') return undefined
|
|
570
|
+
const n = Number(raw)
|
|
571
|
+
if (!Number.isInteger(n) || n < 1 || n > 16) throw new Error('--concurrency takes a whole number from 1 to 16, e.g. `--concurrency 1` to run Agent cases one at a time')
|
|
572
|
+
return n
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Run `work` over `items`, at most `limit` at a time, keeping each result at its item's place. */
|
|
576
|
+
async function pool<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
577
|
+
const out: R[] = new Array(items.length)
|
|
578
|
+
let next = 0
|
|
579
|
+
const lane = async () => {
|
|
580
|
+
for (let i = next++; i < items.length; i = next++) out[i] = await work(items[i]!, i)
|
|
581
|
+
}
|
|
582
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane))
|
|
583
|
+
return out
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** One at a time, in the order asked: cases that change records for real never run alongside each other. */
|
|
587
|
+
function serial(): <R>(work: () => Promise<R>) => Promise<R> {
|
|
588
|
+
let tail: Promise<unknown> = Promise.resolve()
|
|
589
|
+
return <R>(work: () => Promise<R>) => {
|
|
590
|
+
const run = tail.then(work, work)
|
|
591
|
+
tail = run.catch(() => {})
|
|
592
|
+
return run
|
|
227
593
|
}
|
|
228
|
-
return { reply, toolCalls: [...calls.values()] }
|
|
229
594
|
}
|
|
230
595
|
|
|
231
596
|
export async function runCases(args: {
|
|
@@ -234,59 +599,180 @@ export async function runCases(args: {
|
|
|
234
599
|
files: CaseFile[]
|
|
235
600
|
live: boolean
|
|
236
601
|
onResult: (r: CaseResult) => void
|
|
602
|
+
/**
|
|
603
|
+
* Why an Agent's draft is not what its files say (`agentFilesProblem`), or
|
|
604
|
+
* null when it is: its cases fail with that instead of testing stale files.
|
|
605
|
+
* Not asked with `live`, which tests the published version.
|
|
606
|
+
*/
|
|
607
|
+
staleDraft?: (slug: string) => string | null
|
|
608
|
+
/**
|
|
609
|
+
* How many conversation cases run at once (default `DEFAULT_CASE_CONCURRENCY`).
|
|
610
|
+
* Cases that change records for real (`realActions`, a `decision`) still run
|
|
611
|
+
* one at a time, so two never write the same records together.
|
|
612
|
+
*/
|
|
613
|
+
concurrency?: number
|
|
614
|
+
/** The pause before a case's one retry after a provider limit (default `PROVIDER_RETRY_DELAY_MS`). */
|
|
615
|
+
retryDelayMs?: number
|
|
616
|
+
/** How a pause is waited out (tests pass a quick one). */
|
|
617
|
+
wait?: (ms: number) => Promise<void>
|
|
618
|
+
/** One conversation case, sent to the Agent (tests pass a stand-in). */
|
|
619
|
+
runOne?: (api: Api, target: CaseTarget, c: TestCase, opts?: ConversationCaseOptions) => Promise<CaseResult>
|
|
620
|
+
/** The Agent a file's cases talk to (`chatTarget`; tests pass a stand-in). */
|
|
621
|
+
target?: (api: Api, slug: string, live: boolean) => Promise<{ agentId: string; agentName: string }>
|
|
237
622
|
}): Promise<CaseResult[]> {
|
|
238
623
|
const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
|
|
239
624
|
const results: CaseResult[] = []
|
|
240
|
-
|
|
625
|
+
const queued: Array<{ target: CaseTarget; c: TestCase }> = []
|
|
626
|
+
// A credit refusal names a model the runtime offers directly from another provider whose key it holds
|
|
627
|
+
// (the providers the runtime applied keys for; never a key), and how to make it the default.
|
|
628
|
+
const told = (r: CaseResult): CaseResult => (r.notRun?.kind === 'credits' ? { ...r, notRun: withDirectModel(r.notRun, directModelFor(args.creds.modelProviders, r.notRun.provider)) } : r)
|
|
629
|
+
for (const whole of args.files) {
|
|
630
|
+
if (whole.problem) {
|
|
631
|
+
const r: CaseResult = { agent: whole.agent, file: whole.file, name: '(file)', passed: false, reply: '', reason: whole.problem, ms: 0 }
|
|
632
|
+
results.push(r)
|
|
633
|
+
args.onResult(r)
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
// A case that breaks the format fails with where, and is not sent to the Agent.
|
|
637
|
+
for (const c of whole.cases.filter((c) => c.problems?.length)) {
|
|
638
|
+
const r: CaseResult = { agent: whole.agent, file: whole.file, name: c.name, passed: false, reply: '', reason: c.problems!.join('; '), ms: 0 }
|
|
639
|
+
results.push(r)
|
|
640
|
+
args.onResult(r)
|
|
641
|
+
}
|
|
642
|
+
const all = { ...whole, cases: whole.cases.filter((c) => !c.problems?.length) }
|
|
643
|
+
if (!all.cases.length) continue
|
|
644
|
+
// A draft that is not what the files say fails every case with why, scenarios too.
|
|
645
|
+
const stale = args.live ? null : (args.staleDraft?.(all.agent) ?? null)
|
|
241
646
|
// Scenarios run on the runtime's evaluation runner, the rest as conversations here.
|
|
242
|
-
const scenarios = all.cases.filter((c) => c.scenario)
|
|
243
|
-
const file = { ...all, cases: all.cases.filter((c) => !c.scenario) }
|
|
647
|
+
const scenarios = stale ? [] : all.cases.filter((c) => c.scenario)
|
|
648
|
+
const file = { ...all, cases: stale ? all.cases : all.cases.filter((c) => !c.scenario) }
|
|
244
649
|
if (scenarios.length) {
|
|
245
|
-
for (const r of await runScenarios(api, all, scenarios, args.live)) {
|
|
650
|
+
for (const r of (await runScenarios(api, all, scenarios, args.live)).map(told)) {
|
|
246
651
|
results.push(r)
|
|
247
652
|
args.onResult(r)
|
|
248
653
|
}
|
|
249
654
|
}
|
|
250
655
|
let agentName = file.agent
|
|
251
656
|
let agentId: string | undefined
|
|
252
|
-
let setupError: string | undefined
|
|
253
|
-
|
|
254
|
-
const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(file.agent)}`)
|
|
255
|
-
agentId = agent.id
|
|
256
|
-
if (!args.live) {
|
|
257
|
-
const preview = await call<{ previewAgentSlug: string }>(api, `/v1/config/agents/${agent.id}/test-session`, { body: {} })
|
|
258
|
-
agentName = preview.previewAgentSlug
|
|
259
|
-
}
|
|
260
|
-
} catch (err) {
|
|
261
|
-
// An Agent that is missing or whose draft cannot be tried fails its cases, not the run.
|
|
262
|
-
setupError = (err as Error).message
|
|
263
|
-
}
|
|
264
|
-
for (const c of file.cases) {
|
|
265
|
-
const started = Date.now()
|
|
266
|
-
let result: CaseResult
|
|
267
|
-
let conversationId: string | undefined
|
|
657
|
+
let setupError: string | undefined = stale ?? undefined
|
|
658
|
+
if (!setupError && file.cases.length) {
|
|
268
659
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
let reply = ''
|
|
273
|
-
const toolCalls: ToolCall[] = []
|
|
274
|
-
for (const turn of c.conversation ?? []) {
|
|
275
|
-
if (typeof turn.user !== 'string') continue
|
|
276
|
-
const answer = await sendTurn(api, session.id, agentName, turn.user)
|
|
277
|
-
reply = answer.reply
|
|
278
|
-
toolCalls.push(...answer.toolCalls)
|
|
279
|
-
}
|
|
280
|
-
const verdict = judge(c, reply, toolCalls)
|
|
281
|
-
result = { agent: file.agent, file: file.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, agentId, conversationId }
|
|
660
|
+
const target = await (args.target ?? chatTarget)(api, file.agent, args.live)
|
|
661
|
+
agentId = target.agentId
|
|
662
|
+
agentName = target.agentName
|
|
282
663
|
} catch (err) {
|
|
283
|
-
|
|
664
|
+
// An Agent that is missing or whose draft cannot be tried fails its cases, not the run.
|
|
665
|
+
setupError = (err as Error).message
|
|
284
666
|
}
|
|
285
|
-
results.push(result)
|
|
286
|
-
args.onResult(result)
|
|
287
667
|
}
|
|
668
|
+
const target: CaseTarget = { agent: file.agent, file: file.file, agentName, ...(agentId ? { agentId } : {}), ...(setupError ? { setupError } : {}), workspaceId: args.creds.workspaceId }
|
|
669
|
+
for (const c of file.cases) queued.push({ target, c })
|
|
670
|
+
}
|
|
671
|
+
// The conversation cases, a few at a time; each retried once, after a pause, when the provider refused it for its limits.
|
|
672
|
+
const runOne = args.runOne ?? runConversationCase
|
|
673
|
+
const wait = args.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
674
|
+
const delay = args.retryDelayMs ?? PROVIDER_RETRY_DELAY_MS
|
|
675
|
+
const writes = serial()
|
|
676
|
+
// A refusal every later case would meet too (the provider account's credit or key, a runtime that
|
|
677
|
+
// does not answer): the cases not sent yet are not sent, and say why, instead of each failing alike.
|
|
678
|
+
let stopped: { notRun: CaseNotRun; by: string } | null = null
|
|
679
|
+
const once = async ({ target, c }: { target: CaseTarget; c: TestCase }): Promise<CaseResult> => {
|
|
680
|
+
if (stopped) return { agent: target.agent, file: target.file, name: c.name, passed: false, reply: '', reason: `not run: ${stopped.notRun.what} at ${stopped.by}, so the cases after it were not sent`, ms: 0, notRun: { ...stopped.notRun, skipped: true } }
|
|
681
|
+
const attempt = () => (usesRealActions(c) ? writes(() => runOne(api, target, c, { wait })) : runOne(api, target, c, { wait }))
|
|
682
|
+
const first = await attempt()
|
|
683
|
+
if (first.passed || !first.reason || !isProviderLimit(first.reason)) return first
|
|
684
|
+
await wait(delay)
|
|
685
|
+
const second = await attempt()
|
|
686
|
+
const said = first.reason.replace(/\s+/g, ' ').slice(0, 200)
|
|
687
|
+
return { ...second, retried: said, ...(second.passed ? {} : { reason: `${second.reason ?? 'failed'} (run twice: the provider refused the first try for its limits: ${said})` }) }
|
|
688
|
+
}
|
|
689
|
+
const ran = await pool(queued, Math.max(1, Math.floor(args.concurrency ?? DEFAULT_CASE_CONCURRENCY)), async (q) => {
|
|
690
|
+
const r = told(await once(q))
|
|
691
|
+
if (!stopped && r.notRun && !r.notRun.skipped && stopsTheRun(r.notRun)) stopped = { notRun: r.notRun, by: `${r.agent} · ${r.name}` }
|
|
692
|
+
args.onResult(r)
|
|
693
|
+
return r
|
|
694
|
+
})
|
|
695
|
+
return [...results, ...ran]
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Where a file's conversation cases go: the Agent, the name a conversation addresses, and why they can't run when they can't. */
|
|
699
|
+
export interface CaseTarget {
|
|
700
|
+
agent: string
|
|
701
|
+
file: string
|
|
702
|
+
agentName: string
|
|
703
|
+
agentId?: string
|
|
704
|
+
setupError?: string
|
|
705
|
+
workspaceId: string
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export interface ConversationCaseOptions {
|
|
709
|
+
/** How a turn's `waitSeconds` is waited out (tests pass a quick one). */
|
|
710
|
+
wait?: (ms: number) => Promise<void>
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** A turn's `waitSeconds` as the runner keeps it: a whole number of seconds from 0 to `MAX_TURN_WAIT_SECONDS`. */
|
|
714
|
+
export function turnWaitSeconds(turn: CaseTurn): number {
|
|
715
|
+
const n = Number(turn.waitSeconds)
|
|
716
|
+
return Number.isFinite(n) && n > 0 ? Math.min(MAX_TURN_WAIT_SECONDS, Math.floor(n)) : 0
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** What a case's waits say in a run's output: `waited 60 s before turn 2`. */
|
|
720
|
+
export function waitedWords(waited: CaseResult['waited']): string {
|
|
721
|
+
return waited?.length ? `waited ${waited.map((w) => `${w.seconds} s before turn ${w.turn}`).join(', ')}` : ''
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** One conversation case: a fresh conversation, its turns, its decision, and the verdict. */
|
|
725
|
+
export async function runConversationCase(api: Api, target: CaseTarget, c: TestCase, opts: ConversationCaseOptions = {}): Promise<CaseResult> {
|
|
726
|
+
const started = Date.now()
|
|
727
|
+
const wait = opts.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
728
|
+
const gaps: NonNullable<CaseResult['waited']> = []
|
|
729
|
+
const waitedField = () => (gaps.length ? { waited: gaps } : {})
|
|
730
|
+
const { agentId } = target
|
|
731
|
+
let conversationId: string | undefined
|
|
732
|
+
try {
|
|
733
|
+
if (target.setupError) throw new Error(target.setupError)
|
|
734
|
+
const session = await call<{ id: string }>(api, '/v1/sessions', { body: { agentName: target.agentName, workspaceId: target.workspaceId } })
|
|
735
|
+
conversationId = session.id
|
|
736
|
+
// Before the first message; never sent for a simulated case (without the flag it means real).
|
|
737
|
+
// One setup call: real actions and the case's variables together.
|
|
738
|
+
if (agentId) await setUpConversation(api, agentId, session.id, target.agent, { realActions: usesRealActions(c), ...(isRecord(c.variables) ? { variables: c.variables } : {}) })
|
|
739
|
+
let reply = ''
|
|
740
|
+
const toolCalls: ToolCall[] = []
|
|
741
|
+
let lastTurnCalls: ToolCall[] = []
|
|
742
|
+
let waited: PendingDecision | undefined
|
|
743
|
+
const decide = c.decision === 'approve' || c.decision === 'deny' ? c.decision : undefined
|
|
744
|
+
let userTurn = 0
|
|
745
|
+
for (const turn of c.conversation ?? []) {
|
|
746
|
+
if (typeof turn.user !== 'string') continue
|
|
747
|
+
userTurn++
|
|
748
|
+
// A gap the case keeps before this turn (`waitSeconds`), at most five minutes.
|
|
749
|
+
const seconds = turnWaitSeconds(turn)
|
|
750
|
+
if (seconds) {
|
|
751
|
+
await wait(seconds * 1000)
|
|
752
|
+
gaps.push({ turn: userTurn, seconds })
|
|
753
|
+
}
|
|
754
|
+
const answer = await sendTurn(api, session.id, target.agentName, turn.user, decide)
|
|
755
|
+
reply = answer.reply
|
|
756
|
+
toolCalls.push(...answer.toolCalls)
|
|
757
|
+
lastTurnCalls = answer.toolCalls
|
|
758
|
+
// A turn parked on a decision ends the case there: a later turn would be refused.
|
|
759
|
+
if (answer.pending) {
|
|
760
|
+
waited = answer.pending
|
|
761
|
+
break
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Waiting for a person is a failure unless the case expects it (`decision: expect`).
|
|
765
|
+
const verdict = waited && c.decision !== 'expect'
|
|
766
|
+
? { passed: false, reason: waited.approvalId ? `turn waits on an approval for ${waited.tool}; decide it (\`decision: approve\` or \`deny\`) or mark the case \`decision: expect\`` : `turn waits on ${waited.tool}; mark the case \`decision: expect\`` }
|
|
767
|
+
: !waited && c.decision === 'expect'
|
|
768
|
+
? { passed: false, reason: 'the case expects a decision, and no turn waited for one' }
|
|
769
|
+
: judge(c, reply, toolCalls, lastTurnCalls)
|
|
770
|
+
return { agent: target.agent, file: target.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, toolCalls: toolCalls.length, ...waitedField() }
|
|
771
|
+
} catch (err) {
|
|
772
|
+
const reason = (err as Error).message
|
|
773
|
+
const notRun = caseCouldNotRun(reason)
|
|
774
|
+
return { agent: target.agent, file: target.file, name: c.name, passed: false, reply: '', reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, ...waitedField(), ...(notRun ? { notRun } : {}) }
|
|
288
775
|
}
|
|
289
|
-
return results
|
|
290
776
|
}
|
|
291
777
|
|
|
292
778
|
const SCENARIO_POLL_MS = 3_000
|
|
@@ -309,7 +795,10 @@ export function scenarioDataset(file: CaseFile): string {
|
|
|
309
795
|
export async function runScenarios(api: Api, file: CaseFile, cases: TestCase[], live: boolean, wait: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))): Promise<CaseResult[]> {
|
|
310
796
|
const started = Date.now()
|
|
311
797
|
const dataset = scenarioDataset(file)
|
|
312
|
-
const fail = (reason: string, agentId?: string): CaseResult[] =>
|
|
798
|
+
const fail = (reason: string, agentId?: string): CaseResult[] => {
|
|
799
|
+
const notRun = caseCouldNotRun(reason)
|
|
800
|
+
return cases.map((c) => ({ agent: file.agent, file: file.file, name: c.name, passed: false, reply: '', reason, ms: Date.now() - started, agentId, ...(notRun ? { notRun } : {}) }))
|
|
801
|
+
}
|
|
313
802
|
let agentId: string | undefined
|
|
314
803
|
try {
|
|
315
804
|
const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(file.agent)}`)
|
|
@@ -341,7 +830,9 @@ export async function runScenarios(api: Api, file: CaseFile, cases: TestCase[],
|
|
|
341
830
|
const reply = typeof at?.output === 'string' ? at.output : ''
|
|
342
831
|
const passed = outcome === 'pass'
|
|
343
832
|
const reason = passed ? undefined : !at ? 'the run has no result for this scenario' : at.error ? at.error : `the scenario ${OUTCOME_WORDS[outcome ?? ''] ?? 'did not finish'}`
|
|
344
|
-
|
|
833
|
+
// An item the provider refused never ran: its error says so.
|
|
834
|
+
const notRun = !passed && at?.error ? caseCouldNotRun(at.error) : null
|
|
835
|
+
return { agent: file.agent, file: file.file, name: c.name, passed, reply, ...(reason ? { reason } : {}), ms: Date.now() - started, agentId, ...(notRun ? { notRun } : {}) }
|
|
345
836
|
})
|
|
346
837
|
} catch (err) {
|
|
347
838
|
return fail((err as Error).message, agentId)
|