@frontera-sdk/cli 1.52.45 → 1.52.47
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/package.json +4 -4
- package/src/api/apps-api.ts +3 -1
- package/src/api/automation-api.ts +32 -0
- package/src/api/credential-failure.ts +1 -1
- package/src/api/governed-action-api.ts +24 -0
- package/src/api/media-api.ts +24 -0
- package/src/api/platform-api.ts +13 -0
- package/src/binding-registry.ts +9 -2
- package/src/blueprint/compile.ts +3 -1
- package/src/blueprint/ontology-edit-plan.ts +71 -4
- package/src/blueprint/org-name-check.ts +64 -0
- package/src/blueprint/tree.ts +28 -0
- package/src/commands/action/deploy.ts +22 -5
- package/src/commands/action/index-commands.ts +2 -0
- package/src/commands/action/retire.ts +60 -0
- package/src/commands/agent/compose.ts +1 -0
- package/src/commands/agent/index-commands.ts +42 -0
- package/src/commands/app/deploy.ts +1 -1
- package/src/commands/app/dev.ts +2 -2
- package/src/commands/app/save.ts +1 -1
- package/src/commands/auth/add.ts +5 -1
- package/src/commands/auth/current.ts +6 -0
- package/src/commands/auth/index-commands.ts +3 -0
- package/src/commands/auth/login.ts +84 -0
- package/src/commands/auth/use.ts +21 -2
- package/src/commands/automation/bindings.ts +54 -10
- package/src/commands/automation/index-commands.ts +2 -3
- package/src/commands/blueprint/bind.ts +12 -1
- package/src/commands/blueprint/declarative.ts +6 -2
- package/src/commands/conversation/index-commands.ts +66 -0
- package/src/commands/conversation/local-runtime.ts +18 -0
- package/src/commands/local/check.ts +57 -11
- package/src/commands/local/data.ts +75 -7
- package/src/commands/local/dev.ts +77 -9
- package/src/commands/local/embeddings.ts +74 -0
- package/src/commands/local/index-commands.ts +133 -11
- package/src/commands/local/new.ts +78 -0
- package/src/commands/local/publish.ts +34 -0
- package/src/commands/local/steps.ts +105 -0
- package/src/commands/local/sync-request.ts +69 -23
- package/src/commands/local/sync.ts +2 -0
- package/src/commands/media/index-commands.ts +58 -3
- package/src/commands/registry.ts +11 -3
- package/src/commands/trace/index-commands.ts +56 -0
- package/src/commands/workspace/index-commands.ts +14 -7
- package/src/config.ts +4 -1
- package/src/dev-broker.ts +15 -1
- package/src/device-login.ts +143 -0
- package/src/exit.ts +36 -8
- package/src/flag-help.ts +30 -0
- package/src/forge/claude-adapter.ts +17 -4
- package/src/forge/permission-rules.ts +158 -0
- package/src/forge/touches.ts +7 -6
- package/src/function-outside-imports.ts +13 -2
- package/src/function-source.ts +90 -0
- package/src/harness.ts +4 -4
- package/src/key-reach.ts +64 -0
- package/src/local/local-actions.ts +15 -3
- package/src/local/local-apply-error.ts +26 -0
- package/src/local/local-apps.ts +9 -3
- package/src/local/local-blueprint-schemas.ts +15 -1
- package/src/local/local-confidential.ts +81 -0
- package/src/local/local-data-load.ts +130 -0
- package/src/local/local-dev-references.ts +24 -4
- package/src/local/local-embedding-setup.ts +62 -0
- package/src/local/local-jobs.ts +108 -0
- package/src/local/local-new.ts +148 -0
- package/src/local/local-org-settings.ts +1 -1
- package/src/local/local-person-steps.ts +340 -0
- package/src/local/local-personas.ts +16 -5
- package/src/local/local-portability.ts +22 -1
- package/src/local/local-publish.ts +26 -0
- package/src/local/local-request-evidence.ts +35 -6
- package/src/local/local-runtime-history.ts +4 -3
- package/src/local/local-said-once.ts +51 -0
- package/src/local/local-scaffold.ts +40 -2
- package/src/local/local-schemas.ts +60 -11
- package/src/local/local-stand-ins.ts +44 -0
- package/src/local/local-supervisor.ts +22 -0
- package/src/local/local-sync-models.ts +5 -2
- package/src/local/local-sync-names.ts +3 -0
- package/src/local/local-sync-portable.ts +64 -15
- package/src/local/local-sync-precheck.ts +30 -1
- package/src/local/local-sync-push.ts +81 -8
- package/src/local/local-sync-remote.ts +10 -7
- package/src/local/local-test-models.ts +173 -0
- package/src/local/local-test-runner.ts +232 -25
- package/src/local/local-test-scope.ts +174 -0
- package/src/local/local-ungranted-reads.ts +55 -0
- package/src/local/local-writers.ts +171 -0
- package/src/main.ts +75 -17
- package/src/mcp/browser/ax-snapshot.ts +13 -1
- package/src/mcp/browser/page-scripts.ts +1 -0
- package/src/mcp/mcp-conversations.ts +171 -0
- package/src/mcp/mcp-server.ts +4 -1
- package/src/mcp/mcp-traces.ts +7 -2
- package/src/mcp/tools/agent-tools.ts +94 -2
- package/src/mcp/tools/project-tools.ts +149 -13
- package/src/mcp/tools/test-tools.ts +64 -13
- package/src/output.ts +22 -7
- package/src/pack.ts +16 -0
- package/src/project-context.ts +18 -5
- package/src/project.ts +4 -0
- package/src/remote-guard.ts +52 -0
- package/src/vendor/kit-assets.json +11 -11
|
@@ -29,19 +29,36 @@ import type { LocalCredentials } from './local-state'
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
/** A check graded in code, as the platform grades an item's `expectations` (`expectation-grading.ts`). */
|
|
32
|
+
/**
|
|
33
|
+
* Which turn a check reads (Forge improvement plan L3, T10): a person turn by
|
|
34
|
+
* its number (1 is the first), `last`, `any` (one turn passes) or `all`
|
|
35
|
+
* (every turn passes). A reply check reads the last reply unless it says; a
|
|
36
|
+
* tool check reads every turn's calls (`all`) unless it says.
|
|
37
|
+
*/
|
|
38
|
+
export type CheckTurn = number | 'last' | 'any' | 'all'
|
|
39
|
+
|
|
32
40
|
export type TestCheck =
|
|
33
|
-
| { type: 'contains'; value: string; text?: string }
|
|
34
|
-
|
|
41
|
+
| { type: 'contains'; value: string; text?: string; turn?: CheckTurn }
|
|
42
|
+
/** The reply must not say this (any case): a phrase the Agent must never use. */
|
|
43
|
+
| { type: 'not_contains'; value: string; text?: string; turn?: CheckTurn }
|
|
44
|
+
| { type: 'regex'; value: string; flags?: string; text?: string; turn?: CheckTurn }
|
|
35
45
|
| {
|
|
36
46
|
type: 'tool_called' | 'tool_not_called'
|
|
37
47
|
tool: string
|
|
48
|
+
/** Arguments the call carries; `{ absent: true }` as a value says that argument must not be there (L3). */
|
|
38
49
|
argsSubset?: Record<string, unknown>
|
|
39
50
|
mode?: 'exact' | 'ignore' | 'subset' | 'superset'
|
|
40
51
|
text?: string
|
|
41
|
-
/** The calls of every turn (`all`, the default
|
|
42
|
-
turn?:
|
|
52
|
+
/** The calls of every turn (`all`, the default, or `any`), only of the last one, or of turn n. */
|
|
53
|
+
turn?: CheckTurn
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/** One person turn as the case ran it: the reply and the calls it made. */
|
|
57
|
+
export interface TurnSeen {
|
|
58
|
+
reply: string
|
|
59
|
+
toolCalls: ToolCall[]
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
/**
|
|
46
63
|
* One turn of a case's conversation: a person's message (sent), or an
|
|
47
64
|
* assistant's (kept for whoever reads the case). `waitSeconds` on a person's
|
|
@@ -60,9 +77,15 @@ export interface TestCase {
|
|
|
60
77
|
expected?: string
|
|
61
78
|
/** Pass when the reply contains each of these, instead of matching exactly. */
|
|
62
79
|
expectedContains?: string[]
|
|
80
|
+
/** Phrases the last reply must not contain, any case (L3). */
|
|
81
|
+
notContains?: string[]
|
|
63
82
|
/** Checks on the reply and the tools called; every one must pass too. */
|
|
64
83
|
checks?: TestCheck[]
|
|
65
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Expectations in words, graded by the expectation judge with the grading
|
|
86
|
+
* model, on the local runtime as on the platform (X1), after every other
|
|
87
|
+
* check passed.
|
|
88
|
+
*/
|
|
66
89
|
judged?: string[]
|
|
67
90
|
/** Human-certified on the platform. */
|
|
68
91
|
gold?: boolean
|
|
@@ -115,6 +138,8 @@ export interface CaseResult {
|
|
|
115
138
|
startedAt?: string
|
|
116
139
|
/** How many tools the Agent called, for efficiency against a baseline. */
|
|
117
140
|
toolCalls?: number
|
|
141
|
+
/** The tools it called, by name, once each: what tells a failure on a local stand-in from the Agent's (X2). */
|
|
142
|
+
toolNames?: string[]
|
|
118
143
|
/**
|
|
119
144
|
* The waits the case kept between turns (`waitSeconds`), in seconds, in
|
|
120
145
|
* order: said in the run's output, since such a case takes that long.
|
|
@@ -133,6 +158,21 @@ export interface CaseResult {
|
|
|
133
158
|
* failing, and never recorded as the case's last outcome.
|
|
134
159
|
*/
|
|
135
160
|
notRun?: CaseNotRun
|
|
161
|
+
/**
|
|
162
|
+
* The case's `judged` expectations, graded on the local runtime by the
|
|
163
|
+
* expectation judge with the grading model (`local model-default --for
|
|
164
|
+
* tests`), each with the judge's evidence. Absent when the case has none,
|
|
165
|
+
* or failed its other checks first.
|
|
166
|
+
*/
|
|
167
|
+
judged?: Array<{ text: string; passed: boolean; evidence: string }>
|
|
168
|
+
/** Why its `judged` expectations weren't graded (no grading model, the runtime refused); the rest of the case was. */
|
|
169
|
+
judgedNotGraded?: string
|
|
170
|
+
/**
|
|
171
|
+
* `--repeat N`: how the case's runs went. A case that passes some runs and
|
|
172
|
+
* fails others is `flaky`; it passes when its pass rate is at least the
|
|
173
|
+
* run's threshold, and fails below it. Runs that couldn't run don't count.
|
|
174
|
+
*/
|
|
175
|
+
repeat?: { runs: number; passes: number; passRate: number; flaky: boolean; notRun?: number }
|
|
136
176
|
}
|
|
137
177
|
|
|
138
178
|
export interface CaseFile {
|
|
@@ -236,11 +276,13 @@ function normalise(text: string): string {
|
|
|
236
276
|
}
|
|
237
277
|
|
|
238
278
|
/** `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 } {
|
|
279
|
+
export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = [], lastTurnCalls?: ToolCall[], turns?: TurnSeen[]): { passed: boolean; reason?: string } {
|
|
240
280
|
const answer = judgeAnswer(c, reply)
|
|
241
281
|
if (!answer.passed) return answer
|
|
282
|
+
const said = (c.notContains ?? []).filter((s) => typeof s === 'string' && s && normalise(reply).toLowerCase().includes(normalise(s).toLowerCase()))
|
|
283
|
+
if (said.length) return { passed: false, reason: `reply says what the case doesn't allow: ${said.join(', ')}` }
|
|
242
284
|
for (const check of c.checks ?? []) {
|
|
243
|
-
const failed = runCheck(check, reply, toolCalls, lastTurnCalls)
|
|
285
|
+
const failed = runCheck(check, reply, toolCalls, lastTurnCalls, turns)
|
|
244
286
|
if (failed) return { passed: false, reason: failed }
|
|
245
287
|
}
|
|
246
288
|
return { passed: true }
|
|
@@ -251,8 +293,8 @@ function judgeAnswer(c: TestCase, reply: string): { passed: boolean; reason?: st
|
|
|
251
293
|
const missing = c.expectedContains.filter((s) => !normalise(reply).toLowerCase().includes(normalise(s).toLowerCase()))
|
|
252
294
|
return missing.length ? { passed: false, reason: `reply does not mention: ${missing.join(', ')}` } : { passed: true }
|
|
253
295
|
}
|
|
254
|
-
// Checks alone are an expectation.
|
|
255
|
-
if (c.expected === undefined) return c.checks?.length ? { passed: true } : { passed: false, reason: 'the case has no `expected`, `expectedContains` or `checks`' }
|
|
296
|
+
// Checks alone are an expectation, and so are phrases it must not say.
|
|
297
|
+
if (c.expected === undefined) return c.checks?.length || c.notContains?.length ? { passed: true } : { passed: false, reason: 'the case has no `expected`, `expectedContains`, `notContains` or `checks`' }
|
|
256
298
|
return normalise(reply) === normalise(c.expected) ? { passed: true } : { passed: false, reason: 'reply differs from `expected`' }
|
|
257
299
|
}
|
|
258
300
|
|
|
@@ -263,7 +305,12 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
|
|
|
263
305
|
|
|
264
306
|
const deepEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) || (isRecord(a) && isRecord(b) && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => deepEqual(a[k], b[k])))
|
|
265
307
|
|
|
266
|
-
|
|
308
|
+
/** `{ absent: true }` in a check's arguments: the call must not carry that argument (an empty value counts as not there). */
|
|
309
|
+
const isAbsentMarker = (v: unknown) => isRecord(v) && v.absent === true && Object.keys(v).length === 1
|
|
310
|
+
const isEmpty = (v: unknown) => v === undefined || v === null || v === ''
|
|
311
|
+
|
|
312
|
+
const containsAll = (haystack: Record<string, unknown>, needles: Record<string, unknown>) =>
|
|
313
|
+
Object.entries(needles).every(([k, v]) => (isAbsentMarker(v) ? !(k in haystack) || isEmpty(haystack[k]) : k in haystack && deepEqual(haystack[k], v)))
|
|
267
314
|
|
|
268
315
|
function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall): boolean {
|
|
269
316
|
const mode = check.mode ?? 'superset'
|
|
@@ -273,20 +320,45 @@ function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall):
|
|
|
273
320
|
const { action: _narration, ...rest } = call.args
|
|
274
321
|
const args = 'action' in check.argsSubset ? call.args : rest
|
|
275
322
|
if (mode === 'superset') return containsAll(args, check.argsSubset)
|
|
276
|
-
|
|
277
|
-
|
|
323
|
+
const present = Object.fromEntries(Object.entries(check.argsSubset).filter(([, v]) => !isAbsentMarker(v)))
|
|
324
|
+
const absent = Object.fromEntries(Object.entries(check.argsSubset).filter(([, v]) => isAbsentMarker(v)))
|
|
325
|
+
if (mode === 'subset') return containsAll(present, Object.fromEntries(Object.entries(args).filter(([k]) => !(k in absent)))) && containsAll(args, absent)
|
|
326
|
+
// Exact: the same arguments, and nothing besides; an absent-marked one may be there only empty.
|
|
327
|
+
return containsAll(args, check.argsSubset) && Object.keys(args).every((k) => k in check.argsSubset!)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A check's `turn` as a turn number (1-based), `last`, `any` or `all`; null when it is none of them. */
|
|
331
|
+
function turnOf(turn: unknown): CheckTurn | null {
|
|
332
|
+
if (turn === undefined) return null
|
|
333
|
+
if (turn === 'last' || turn === 'any' || turn === 'all') return turn
|
|
334
|
+
return typeof turn === 'number' && Number.isInteger(turn) && turn >= 1 ? turn : null
|
|
278
335
|
}
|
|
279
336
|
|
|
280
337
|
/** Why a check failed, or null when it passed. */
|
|
281
|
-
export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[]): string | null {
|
|
338
|
+
export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[], turns?: TurnSeen[]): string | null {
|
|
282
339
|
const label = check.text ? ` (${check.text})` : ''
|
|
283
|
-
const
|
|
284
|
-
|
|
340
|
+
const turn = turnOf((check as { turn?: unknown }).turn)
|
|
341
|
+
if ((check as { turn?: unknown }).turn !== undefined && turn === null) return `the check's \`turn\` is a turn number from 1, last, any or all (it is ${JSON.stringify((check as { turn?: unknown }).turn)})${label}`
|
|
342
|
+
// A turn the case never reached fails the check by name, rather than reading another turn.
|
|
343
|
+
if (typeof turn === 'number' && turns && turn > turns.length) return `the case has no turn ${turn} (it ran ${turns.length})${label}`
|
|
285
344
|
// 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)) {
|
|
345
|
+
if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) {
|
|
287
346
|
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
347
|
}
|
|
348
|
+
// A reply check on several turns: any one passing, or every one (L3).
|
|
349
|
+
if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (turn === 'any' || turn === 'all') && turns?.length) {
|
|
350
|
+
const failures = turns.map((t, i) => [i + 1, runCheck({ ...check, turn: 'last' } as TestCheck, t.reply, t.toolCalls, t.toolCalls)] as const)
|
|
351
|
+
if (turn === 'any') return failures.some(([, f]) => f === null) ? null : `no turn passes; the last: ${failures[failures.length - 1]![1]}`
|
|
352
|
+
const failed = failures.find(([, f]) => f !== null)
|
|
353
|
+
return failed ? `turn ${failed[0]}: ${failed[1]}` : null
|
|
354
|
+
}
|
|
355
|
+
if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && typeof turn === 'number' && turns) {
|
|
356
|
+
return runCheck({ ...check, turn: 'last' } as TestCheck, turns[turn - 1]!.reply, turns[turn - 1]!.toolCalls, turns[turn - 1]!.toolCalls)?.replace(/^/, `turn ${turn}: `) ?? null
|
|
357
|
+
}
|
|
358
|
+
const lastOnly = turn === 'last'
|
|
359
|
+
const toolCalls = typeof turn === 'number' && turns ? turns[turn - 1]!.toolCalls : lastOnly && lastTurnCalls ? lastTurnCalls : allCalls
|
|
289
360
|
if (check.type === 'contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? null : `reply does not mention: ${check.value}${label}`
|
|
361
|
+
if (check.type === 'not_contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? `reply says ${check.value}, which the case doesn't allow${label}` : null
|
|
290
362
|
if (check.type === 'regex') {
|
|
291
363
|
if (check.value.length > 500 || NESTED_QUANTIFIER.test(check.value)) return `the pattern ${check.value} can't run safely${label}`
|
|
292
364
|
let pattern: RegExp
|
|
@@ -302,7 +374,7 @@ export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[],
|
|
|
302
374
|
const matched = candidates.some((c) => c.name === check.tool && argsMatch(check, c))
|
|
303
375
|
const said = (c: ToolCall) => `${c.name}${c.parked ? ' (waiting on approval)' : !c.ok ? ' (did not run)' : ''}`
|
|
304
376
|
const made = toolCalls.length ? `calls made: ${[...new Set(toolCalls.map(said))].join(', ')}` : 'no tools were called'
|
|
305
|
-
const where = lastOnly ? ' in the last turn' : ''
|
|
377
|
+
const where = lastOnly ? ' in the last turn' : typeof turn === 'number' ? ` in turn ${turn}` : ''
|
|
306
378
|
if (check.type === 'tool_called') return matched ? null : `no matching call to ${check.tool}${where}; ${made}${label}`
|
|
307
379
|
return matched ? `${check.tool} was called${where}, which the case does not allow${label}` : null
|
|
308
380
|
}
|
|
@@ -619,10 +691,22 @@ export async function runCases(args: {
|
|
|
619
691
|
runOne?: (api: Api, target: CaseTarget, c: TestCase, opts?: ConversationCaseOptions) => Promise<CaseResult>
|
|
620
692
|
/** The Agent a file's cases talk to (`chatTarget`; tests pass a stand-in). */
|
|
621
693
|
target?: (api: Api, slug: string, live: boolean) => Promise<{ agentId: string; agentName: string }>
|
|
694
|
+
/**
|
|
695
|
+
* `--repeat N`: each conversation case runs N times, and its result says how
|
|
696
|
+
* many passed (`combineRuns`). A scenario case runs once: the evaluation
|
|
697
|
+
* runner judges it as a whole.
|
|
698
|
+
*/
|
|
699
|
+
repeat?: number
|
|
700
|
+
/** The pass rate a repeated, flaky case needs to pass (default `DEFAULT_PASS_RATE`). */
|
|
701
|
+
passRate?: number
|
|
702
|
+
/** How a case's `judged` expectations are graded (tests pass a stand-in). */
|
|
703
|
+
grade?: typeof gradeJudged
|
|
622
704
|
}): Promise<CaseResult[]> {
|
|
623
705
|
const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
|
|
624
706
|
const results: CaseResult[] = []
|
|
625
|
-
const queued: Array<{ target: CaseTarget; c: TestCase }> = []
|
|
707
|
+
const queued: Array<{ target: CaseTarget; c: TestCase; group: number }> = []
|
|
708
|
+
const repeat = Math.max(1, Math.min(10, Math.floor(args.repeat ?? 1)))
|
|
709
|
+
let groups = 0
|
|
626
710
|
// A credit refusal names a model the runtime offers directly from another provider whose key it holds
|
|
627
711
|
// (the providers the runtime applied keys for; never a key), and how to make it the default.
|
|
628
712
|
const told = (r: CaseResult): CaseResult => (r.notRun?.kind === 'credits' ? { ...r, notRun: withDirectModel(r.notRun, directModelFor(args.creds.modelProviders, r.notRun.provider)) } : r)
|
|
@@ -666,7 +750,10 @@ export async function runCases(args: {
|
|
|
666
750
|
}
|
|
667
751
|
}
|
|
668
752
|
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)
|
|
753
|
+
for (const c of file.cases) {
|
|
754
|
+
const group = groups++
|
|
755
|
+
for (let n = 0; n < repeat; n++) queued.push({ target, c, group })
|
|
756
|
+
}
|
|
670
757
|
}
|
|
671
758
|
// The conversation cases, a few at a time; each retried once, after a pause, when the provider refused it for its limits.
|
|
672
759
|
const runOne = args.runOne ?? runConversationCase
|
|
@@ -678,7 +765,8 @@ export async function runCases(args: {
|
|
|
678
765
|
let stopped: { notRun: CaseNotRun; by: string } | null = null
|
|
679
766
|
const once = async ({ target, c }: { target: CaseTarget; c: TestCase }): Promise<CaseResult> => {
|
|
680
767
|
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
|
|
768
|
+
const options = { wait, ...(args.grade ? { grade: args.grade } : {}) }
|
|
769
|
+
const attempt = () => (usesRealActions(c) ? writes(() => runOne(api, target, c, options)) : runOne(api, target, c, options))
|
|
682
770
|
const first = await attempt()
|
|
683
771
|
if (first.passed || !first.reason || !isProviderLimit(first.reason)) return first
|
|
684
772
|
await wait(delay)
|
|
@@ -686,13 +774,22 @@ export async function runCases(args: {
|
|
|
686
774
|
const said = first.reason.replace(/\s+/g, ' ').slice(0, 200)
|
|
687
775
|
return { ...second, retried: said, ...(second.passed ? {} : { reason: `${second.reason ?? 'failed'} (run twice: the provider refused the first try for its limits: ${said})` }) }
|
|
688
776
|
}
|
|
689
|
-
|
|
777
|
+
// A case's runs, as they come back: said as one result once the last of them is in.
|
|
778
|
+
const runsOf = new Map<number, CaseResult[]>()
|
|
779
|
+
const combined = new Map<number, CaseResult>()
|
|
780
|
+
await pool(queued, Math.max(1, Math.floor(args.concurrency ?? DEFAULT_CASE_CONCURRENCY)), async (q) => {
|
|
690
781
|
const r = told(await once(q))
|
|
691
782
|
if (!stopped && r.notRun && !r.notRun.skipped && stopsTheRun(r.notRun)) stopped = { notRun: r.notRun, by: `${r.agent} · ${r.name}` }
|
|
692
|
-
|
|
783
|
+
const runs = [...(runsOf.get(q.group) ?? []), r]
|
|
784
|
+
runsOf.set(q.group, runs)
|
|
785
|
+
if (runs.length === repeat) {
|
|
786
|
+
const one = combineRuns(runs, args.passRate ?? DEFAULT_PASS_RATE)
|
|
787
|
+
combined.set(q.group, one)
|
|
788
|
+
args.onResult(one)
|
|
789
|
+
}
|
|
693
790
|
return r
|
|
694
791
|
})
|
|
695
|
-
return [...results, ...
|
|
792
|
+
return [...results, ...[...combined.entries()].sort((a, b) => a[0] - b[0]).map(([, r]) => r)]
|
|
696
793
|
}
|
|
697
794
|
|
|
698
795
|
/** Where a file's conversation cases go: the Agent, the name a conversation addresses, and why they can't run when they can't. */
|
|
@@ -708,6 +805,97 @@ export interface CaseTarget {
|
|
|
708
805
|
export interface ConversationCaseOptions {
|
|
709
806
|
/** How a turn's `waitSeconds` is waited out (tests pass a quick one). */
|
|
710
807
|
wait?: (ms: number) => Promise<void>
|
|
808
|
+
/** How a case's `judged` expectations are graded (`gradeJudged`; tests pass a stand-in). */
|
|
809
|
+
grade?: typeof gradeJudged
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Grade a reply against expectations in words with the local runtime's
|
|
814
|
+
* expectation judge (`POST /v1/eval/expectations/grade`), the judge a
|
|
815
|
+
* platform evaluation run uses, on the grading model the runtime has
|
|
816
|
+
* (`frontera local model-default <model> --for tests`).
|
|
817
|
+
*/
|
|
818
|
+
export async function gradeJudged(api: Api, input: { expectations: string[]; conversation: string; reply: string; toolCalls: ToolCall[] }): Promise<{ results: Array<{ text: string; passed: boolean; evidence: string }> }> {
|
|
819
|
+
return call(api, '/v1/eval/expectations/grade', {
|
|
820
|
+
body: {
|
|
821
|
+
expectations: input.expectations,
|
|
822
|
+
conversation: input.conversation.slice(0, 200_000),
|
|
823
|
+
reply: input.reply.slice(0, 200_000),
|
|
824
|
+
toolCalls: input.toolCalls.slice(0, 200).map((c) => ({ name: c.name, ...(c.args === undefined ? {} : { args: c.args }), ok: c.ok })),
|
|
825
|
+
},
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Why grading couldn't run, in words, with what fixes it. */
|
|
830
|
+
export function judgedNotGradedWords(message: string): string {
|
|
831
|
+
if (/answered 404/.test(message)) return 'this runtime is older than judged grading: `frontera local start --update`, then test again'
|
|
832
|
+
if (/answered 409|not configured|no .*model|judge/i.test(message)) return `the runtime has no grading model it can use (${message.replace(/\s+/g, ' ').slice(0, 160)}): choose one with \`frontera local model-default <model> --for tests\``
|
|
833
|
+
return message.replace(/\s+/g, ' ').slice(0, 240)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** `--repeat` as given: a whole number from 1 to 10. Undefined when not given. */
|
|
837
|
+
export function caseRepeat(raw: string | number | undefined): number | undefined {
|
|
838
|
+
if (raw === undefined || raw === '') return undefined
|
|
839
|
+
const n = Number(raw)
|
|
840
|
+
if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('--repeat takes a whole number from 1 to 10, e.g. `--repeat 5` to run each case five times and report its pass rate')
|
|
841
|
+
return n
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** The pass rate a repeated case needs to pass, by default: 4 of 5 runs. */
|
|
845
|
+
export const DEFAULT_PASS_RATE = 0.8
|
|
846
|
+
|
|
847
|
+
/** `--pass-rate` as given: a share from 0 to 1, or a percentage (`80%`). Undefined when not given. */
|
|
848
|
+
export function passRateThreshold(raw: string | number | undefined): number | undefined {
|
|
849
|
+
if (raw === undefined || raw === '') return undefined
|
|
850
|
+
const text = String(raw).trim()
|
|
851
|
+
const n = text.endsWith('%') ? Number(text.slice(0, -1)) / 100 : Number(text)
|
|
852
|
+
if (!Number.isFinite(n) || n <= 0 || n > 1) throw new Error('--pass-rate takes a share above 0 and up to 1 (`0.8`) or a percentage (`80%`): the share of runs a repeated case must pass')
|
|
853
|
+
return n
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* One case's repeated runs as one result (`--repeat N`): passed when every
|
|
858
|
+
* run that ran passed, or when it is flaky and its pass rate is at least
|
|
859
|
+
* `threshold`; the first failing run's reason and conversation otherwise, with
|
|
860
|
+
* how many runs passed. Runs that couldn't run aren't counted; when none ran,
|
|
861
|
+
* the case couldn't run.
|
|
862
|
+
*/
|
|
863
|
+
export function combineRuns(runs: CaseResult[], threshold = DEFAULT_PASS_RATE): CaseResult {
|
|
864
|
+
if (runs.length === 1) return runs[0]!
|
|
865
|
+
const ran = runs.filter((r) => !r.notRun)
|
|
866
|
+
if (!ran.length) return runs[0]!
|
|
867
|
+
const passes = ran.filter((r) => r.passed).length
|
|
868
|
+
const passRate = passes / ran.length
|
|
869
|
+
const flaky = passes > 0 && passes < ran.length
|
|
870
|
+
const passed = passes === ran.length || (flaky && passRate >= threshold)
|
|
871
|
+
const shown = ran.find((r) => !r.passed) ?? ran[ran.length - 1]!
|
|
872
|
+
const mean = (values: Array<number | undefined>) => {
|
|
873
|
+
const known = values.filter((v): v is number => typeof v === 'number')
|
|
874
|
+
return known.length ? Math.round(known.reduce((a, b) => a + b, 0) / known.length) : undefined
|
|
875
|
+
}
|
|
876
|
+
const counted = `passed ${passes} of ${ran.length} runs`
|
|
877
|
+
const toolCalls = mean(ran.map((r) => r.toolCalls))
|
|
878
|
+
return {
|
|
879
|
+
...shown,
|
|
880
|
+
passed,
|
|
881
|
+
ms: mean(ran.map((r) => r.ms)) ?? shown.ms,
|
|
882
|
+
...(toolCalls === undefined ? {} : { toolCalls }),
|
|
883
|
+
reason: passed ? (flaky ? `flaky: ${counted}, at or above the ${Math.round(threshold * 100)}% it needs; one failed: ${shown.reason ?? 'failed'}` : undefined) : `${shown.reason ?? 'failed'} (${counted}${flaky ? `, flaky, below the ${Math.round(threshold * 100)}% it needs` : ''})`,
|
|
884
|
+
repeat: { runs: ran.length, passes, passRate, flaky, ...(runs.length > ran.length ? { notRun: runs.length - ran.length } : {}) },
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** What a repeated case says after its name: `(passed 4 of 5 runs, flaky)`. */
|
|
889
|
+
export function repeatWords(r: CaseResult): string {
|
|
890
|
+
if (!r.repeat) return ''
|
|
891
|
+
return ` (passed ${r.repeat.passes} of ${r.repeat.runs} runs${r.repeat.flaky ? ', flaky' : ''}${r.repeat.notRun ? `; ${r.repeat.notRun} couldn’t run` : ''})`
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** What a case's `judged` expectations came to, after its name: ` (judged: 2 of 2 met)`, or why they weren't graded. */
|
|
895
|
+
export function judgedWords(r: CaseResult): string {
|
|
896
|
+
if (r.judgedNotGraded) return ` (judged expectations not graded: ${r.judgedNotGraded})`
|
|
897
|
+
if (!r.judged?.length) return ''
|
|
898
|
+
return ` (judged: ${r.judged.filter((j) => j.passed).length} of ${r.judged.length} met)`
|
|
711
899
|
}
|
|
712
900
|
|
|
713
901
|
/** A turn's `waitSeconds` as the runner keeps it: a whole number of seconds from 0 to `MAX_TURN_WAIT_SECONDS`. */
|
|
@@ -742,6 +930,10 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
|
|
|
742
930
|
let waited: PendingDecision | undefined
|
|
743
931
|
const decide = c.decision === 'approve' || c.decision === 'deny' ? c.decision : undefined
|
|
744
932
|
let userTurn = 0
|
|
933
|
+
// The conversation as the judge reads it, for the case's `judged` expectations.
|
|
934
|
+
const said: string[] = []
|
|
935
|
+
// Each person turn's reply and calls, for checks on a turn (L3).
|
|
936
|
+
const seen: TurnSeen[] = []
|
|
745
937
|
for (const turn of c.conversation ?? []) {
|
|
746
938
|
if (typeof turn.user !== 'string') continue
|
|
747
939
|
userTurn++
|
|
@@ -752,6 +944,8 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
|
|
|
752
944
|
gaps.push({ turn: userTurn, seconds })
|
|
753
945
|
}
|
|
754
946
|
const answer = await sendTurn(api, session.id, target.agentName, turn.user, decide)
|
|
947
|
+
seen.push({ reply: answer.reply, toolCalls: answer.toolCalls })
|
|
948
|
+
said.push(`user: ${turn.user}`, `assistant: ${answer.reply}`)
|
|
755
949
|
reply = answer.reply
|
|
756
950
|
toolCalls.push(...answer.toolCalls)
|
|
757
951
|
lastTurnCalls = answer.toolCalls
|
|
@@ -766,8 +960,21 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
|
|
|
766
960
|
? { 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
961
|
: !waited && c.decision === 'expect'
|
|
768
962
|
? { passed: false, reason: 'the case expects a decision, and no turn waited for one' }
|
|
769
|
-
: judge(c, reply, toolCalls, lastTurnCalls)
|
|
770
|
-
|
|
963
|
+
: judge(c, reply, toolCalls, lastTurnCalls, seen)
|
|
964
|
+
// The case's `judged` expectations, graded by the runtime's judge once everything else passed.
|
|
965
|
+
let judgedField: Pick<CaseResult, 'judged' | 'judgedNotGraded'> = {}
|
|
966
|
+
const judgedTexts = (c.judged ?? []).filter((j): j is string => typeof j === 'string' && j.trim() !== '')
|
|
967
|
+
if (verdict.passed && judgedTexts.length && !waited) {
|
|
968
|
+
try {
|
|
969
|
+
const graded = await (opts.grade ?? gradeJudged)(api, { expectations: judgedTexts, conversation: said.join('\n'), reply, toolCalls })
|
|
970
|
+
judgedField = { judged: graded.results.map((g) => ({ text: g.text, passed: g.passed, evidence: g.evidence })) }
|
|
971
|
+
const missed = judgedField.judged!.filter((g) => !g.passed)
|
|
972
|
+
if (missed.length) Object.assign(verdict, { passed: false, reason: `judged: ${missed.map((g) => `“${g.text}” not met (${g.evidence})`).join('; ')}` })
|
|
973
|
+
} catch (err) {
|
|
974
|
+
judgedField = { judgedNotGraded: judgedNotGradedWords((err as Error).message) }
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
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, ...(toolCalls.length ? { toolNames: [...new Set(toolCalls.map((t) => t.name))] } : {}), ...waitedField(), ...judgedField }
|
|
771
978
|
} catch (err) {
|
|
772
979
|
const reason = (err as Error).message
|
|
773
980
|
const notRun = caseCouldNotRun(reason)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, normalize } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { loadBuild } from './local-applied'
|
|
5
|
+
import type { Unchanged } from './local-check-inherited'
|
|
6
|
+
import type { LocalProject } from './local-project'
|
|
7
|
+
import { hash, readLock } from './local-sync-plan'
|
|
8
|
+
import { readBase } from './local-sync-snapshot'
|
|
9
|
+
import { findCaseFiles, type CaseFile } from './local-test-runner'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a test run left out and why, and why each Agent's cases are in it
|
|
13
|
+
* (Forge improvement plan T9, X1). Before, a gate's count dropped from 13 to
|
|
14
|
+
* 5 with nothing said, and `local check` ran every Agent's cases, so a rename
|
|
15
|
+
* turned another Agent's flaky case into a blocker.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Cases a run did not take, with why: `3 cases in agents/desk/evals/x.yaml: not marked gate: true`. */
|
|
19
|
+
export interface SkippedCases {
|
|
20
|
+
reason: string
|
|
21
|
+
cases: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The cases under the run's scope that it leaves out, by reason: a file with
|
|
26
|
+
* no cases, cases not marked `gate: true` in a gate run, files that aren't a
|
|
27
|
+
* regression dataset in a `--regression` run, and other Agents' cases when
|
|
28
|
+
* the run names Agents (`local check --agent`).
|
|
29
|
+
*/
|
|
30
|
+
export function skippedCases(root: string, scope: { agents?: readonly string[]; file?: string; regression?: boolean; gate?: boolean } = {}): SkippedCases[] {
|
|
31
|
+
const file = scope.file?.replace(/^\.\//, '')
|
|
32
|
+
const all = findCaseFiles(root).filter((f) => !file || f.file === file)
|
|
33
|
+
const out: SkippedCases[] = []
|
|
34
|
+
const add = (reason: string, cases: number) => out.push({ reason, cases })
|
|
35
|
+
const named = scope.agents?.length ? new Set(scope.agents) : null
|
|
36
|
+
if (named) {
|
|
37
|
+
const others = all.filter((f) => !named.has(f.agent))
|
|
38
|
+
const count = others.reduce((n, f) => n + f.cases.length, 0)
|
|
39
|
+
if (count) add(`other Agents' cases (${[...new Set(others.map((f) => f.agent))].sort().join(', ')}): not named with --agent`, count)
|
|
40
|
+
}
|
|
41
|
+
for (const f of all.filter((f) => !named || named.has(f.agent))) {
|
|
42
|
+
if (!f.cases.length && !f.problem) {
|
|
43
|
+
add(`${f.file} has no cases`, 0)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (scope.regression && !f.regression) {
|
|
47
|
+
add(`${f.file}: not a regression dataset (\`regression: true\`), and the run takes only those`, f.cases.length)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const ungated = scope.gate ? f.cases.filter((c) => c.gate !== true).length : 0
|
|
51
|
+
if (ungated) add(`${f.file}: not marked \`gate: true\`, and the run takes only gate cases`, ungated)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The skipped cases in one line: `5 skipped (3 agents/x/evals/a.yaml: not marked gate: true …; 2 …)`. */
|
|
57
|
+
export function skippedWords(skipped: readonly SkippedCases[]): string {
|
|
58
|
+
const counted = skipped.filter((s) => s.cases > 0)
|
|
59
|
+
const total = counted.reduce((n, s) => n + s.cases, 0)
|
|
60
|
+
const empty = skipped.filter((s) => s.cases === 0)
|
|
61
|
+
return [total ? `${total} skipped (${counted.map((s) => (counted.length === 1 ? s.reason : `${s.cases} ${s.reason}`)).join('; ')})` : '', ...empty.map((s) => s.reason)].filter(Boolean).join('; ')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Why an Agent's cases are in a `local check`, and whether its failures fail it. */
|
|
65
|
+
export interface AgentScope {
|
|
66
|
+
agent: string
|
|
67
|
+
cases: number
|
|
68
|
+
why: string
|
|
69
|
+
/**
|
|
70
|
+
* Its failures are inherited from the workspace: it is unchanged since the
|
|
71
|
+
* pull, or only its name changed. They pass unless `--strict`, as every
|
|
72
|
+
* other step's inherited failures do.
|
|
73
|
+
*/
|
|
74
|
+
inherited: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parseJson = (text: string | null): unknown => {
|
|
78
|
+
if (text === null) return undefined
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(text)
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An agent.json without its name, for telling a rename from a change in what the Agent does. */
|
|
87
|
+
function withoutName(doc: unknown): unknown {
|
|
88
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc
|
|
89
|
+
const { name: _name, ...rest } = doc as Record<string, unknown>
|
|
90
|
+
const config = rest.config
|
|
91
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return rest
|
|
92
|
+
const { name: _configName, ...configRest } = config as Record<string, unknown>
|
|
93
|
+
return { ...rest, config: configRest }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const canonical = (value: unknown): string => JSON.stringify(value, (_k, v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : v))
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether the only change to an Agent since the pull is its name: every one
|
|
100
|
+
* of its files the pull wrote is as written, except agent.json, which differs
|
|
101
|
+
* from the pulled copy only in `config.name` (or a top-level `name`).
|
|
102
|
+
*/
|
|
103
|
+
export function nameOnlyChange(root: string, slug: string): boolean {
|
|
104
|
+
const entry = readLock(root)?.items[`agent/${slug}`]
|
|
105
|
+
if (!entry) return false
|
|
106
|
+
const agentJson = `agents/${slug}/agent.json`
|
|
107
|
+
let renamed = false
|
|
108
|
+
for (const [path, written] of Object.entries(entry.files ?? {})) {
|
|
109
|
+
const full = join(root, path)
|
|
110
|
+
const now = existsSync(full) ? readFileSync(full, 'utf8') : null
|
|
111
|
+
if (now !== null && hash(now) === written) continue
|
|
112
|
+
if (normalize(path) !== normalize(agentJson) || now === null) return false
|
|
113
|
+
const before = parseJson(readBase(root, path))
|
|
114
|
+
const after = parseJson(now)
|
|
115
|
+
if (before === undefined || after === undefined || canonical(withoutName(before)) !== canonical(withoutName(after))) return false
|
|
116
|
+
renamed = true
|
|
117
|
+
}
|
|
118
|
+
return renamed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Each Agent with cases in a check, and why it is in it. Named with
|
|
123
|
+
* `--agent`: those only. Otherwise every Agent; in a pulled project one
|
|
124
|
+
* unchanged since the pull, its cases included, or changed only in its name,
|
|
125
|
+
* has its failures inherited (they pass unless `--strict`).
|
|
126
|
+
*/
|
|
127
|
+
export function agentScope(root: string, files: readonly CaseFile[], unchanged: Unchanged, named: readonly string[] = []): AgentScope[] {
|
|
128
|
+
const pulled = !!readLock(root)
|
|
129
|
+
const bySlug = new Map<string, number>()
|
|
130
|
+
for (const f of files) bySlug.set(f.agent, (bySlug.get(f.agent) ?? 0) + f.cases.length)
|
|
131
|
+
return [...bySlug.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([agent, cases]) => {
|
|
132
|
+
if (named.includes(agent)) return { agent, cases, why: 'named with --agent', inherited: false }
|
|
133
|
+
if (!pulled) return { agent, cases, why: 'every Agent with cases is checked (`--agent <slug>` checks one)', inherited: false }
|
|
134
|
+
const caseFilesSame = files.filter((f) => f.agent === agent).every((f) => unchanged.files.has(normalize(f.file)))
|
|
135
|
+
if (!readLock(root)?.items[`agent/${agent}`]) return { agent, cases, why: 'added here, not pulled', inherited: false }
|
|
136
|
+
if (!caseFilesSame) return { agent, cases, why: 'its test cases changed since the pull', inherited: false }
|
|
137
|
+
if (unchanged.refs.has(`agent/${agent}`)) return { agent, cases, why: 'unchanged since the pull: its failures are inherited unless --strict', inherited: true }
|
|
138
|
+
if (nameOnlyChange(root, agent)) return { agent, cases, why: 'only its name changed since the pull, which is not a change in what it does: its failures are inherited unless --strict', inherited: true }
|
|
139
|
+
return { agent, cases, why: 'its files changed since the pull', inherited: false }
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The Agents that run on another model here than their files name, as the
|
|
145
|
+
* apply said (`it uses <model> here`), one line each: a pass or a failure
|
|
146
|
+
* says less about the model the workspace runs.
|
|
147
|
+
*/
|
|
148
|
+
export function modelSubstitutions(project: LocalProject, organizationId: string | null, slugs: readonly string[]): string[] {
|
|
149
|
+
const build = loadBuild(project, organizationId)
|
|
150
|
+
const out: string[] = []
|
|
151
|
+
for (const slug of [...new Set(slugs)].sort()) {
|
|
152
|
+
const entry = build[`agent:${slug}`]
|
|
153
|
+
for (const a of entry?.adaptations ?? []) if (/\bmodel\b/i.test(a) && /\buses\b/i.test(a)) out.push(`Agent ${slug}: ${a.replace(/^Agent \S+ /, '')}`)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The Agents the project's files make customer-facing: each with an
|
|
160
|
+
* `operating.json` (Forge improvement plan X4, T7; P5 A15). Behavior settings
|
|
161
|
+
* are what make an Agent serve customers, and the built-in Chat neither
|
|
162
|
+
* offers nor runs one; that is inferred, not chosen, until an Agent's
|
|
163
|
+
* audience is its own field (the plan's open question 5). `local check` says
|
|
164
|
+
* which, so a team's own Agent with an `operating.json` is noticed.
|
|
165
|
+
*/
|
|
166
|
+
export function customerFacingAgents(root: string): string[] {
|
|
167
|
+
const dir = join(root, 'agents')
|
|
168
|
+
if (!existsSync(dir)) return []
|
|
169
|
+
return readdirSync(dir).filter((slug) => existsSync(join(dir, slug, 'agent.json')) && existsSync(join(dir, slug, 'operating.json'))).sort()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function customerFacingWords(slugs: readonly string[]): string {
|
|
173
|
+
return `${slugs.join(', ')} ${slugs.length === 1 ? 'serves' : 'serve'} customers, so the built-in Chat neither offers nor runs ${slugs.length === 1 ? 'it' : 'them'}: ${slugs.length === 1 ? 'it has' : 'each has'} behavior settings (operating.json). For an Agent your team uses in Chat, delete its operating.json; in the workspace, \`frontera agent behavior clear <agent>\` and publish`
|
|
174
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { collectAppContract } from '../app-contract'
|
|
5
|
+
import { readSnapshot } from './local-sync-snapshot'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the project's workflows and Apps read that the linked workspace isn't
|
|
9
|
+
* granted (Forge improvement plan X2, T12 step 3). The local runtime grants
|
|
10
|
+
* the workspace every object type, so a workflow or App reading a type the
|
|
11
|
+
* project adds works here. In the workspace it reads only its slice: a change
|
|
12
|
+
* request that creates the type grants it, and a Blueprint published by hand
|
|
13
|
+
* grants nothing. Said as a warning when the Blueprint applies, for a project
|
|
14
|
+
* linked to a workspace; nothing for one that isn't.
|
|
15
|
+
*/
|
|
16
|
+
export interface UngrantedRead {
|
|
17
|
+
objectType: string
|
|
18
|
+
/** `workflow/intake`, `app/desk`. */
|
|
19
|
+
readers: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TYPE_REF = /objectType\/([A-Z][A-Za-z0-9]*)/g
|
|
23
|
+
|
|
24
|
+
function yamlFiles(dir: string): string[] {
|
|
25
|
+
return existsSync(dir) ? readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => join(dir, f)) : []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ungrantedReads(root: string): UngrantedRead[] {
|
|
29
|
+
const slice = readSnapshot(root)?.names?.objectType
|
|
30
|
+
if (!slice) return []
|
|
31
|
+
const granted = new Set(Object.keys(slice))
|
|
32
|
+
const projectTypes = new Set(yamlFiles(join(root, 'blueprint', 'object-types')).map((p) => basename(p).replace(/\.ya?ml$/, '')))
|
|
33
|
+
const readers = new Map<string, Set<string>>()
|
|
34
|
+
const read = (type: string, by: string) => {
|
|
35
|
+
if (!projectTypes.has(type) || granted.has(type)) return
|
|
36
|
+
readers.set(type, (readers.get(type) ?? new Set()).add(by))
|
|
37
|
+
}
|
|
38
|
+
for (const path of yamlFiles(join(root, 'workflows'))) {
|
|
39
|
+
const name = basename(path).replace(/\.ya?ml$/, '')
|
|
40
|
+
for (const m of readFileSync(path, 'utf8').matchAll(TYPE_REF)) read(m[1]!, `workflow/${name}`)
|
|
41
|
+
}
|
|
42
|
+
const apps = join(root, 'apps')
|
|
43
|
+
if (existsSync(apps)) {
|
|
44
|
+
for (const slug of readdirSync(apps)) {
|
|
45
|
+
const dir = join(apps, slug)
|
|
46
|
+
if (!statSync(dir).isDirectory() || !existsSync(join(dir, 'frontera.config.json'))) continue
|
|
47
|
+
for (const type of Object.keys(collectAppContract(dir).objectTypes)) read(type, `app/${slug}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...readers].sort(([a], [b]) => a.localeCompare(b)).map(([objectType, by]) => ({ objectType, readers: [...by].sort() }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ungrantedReadLines(found: UngrantedRead[]): string[] {
|
|
54
|
+
return found.map((f) => ` warning: ${f.readers.join(', ')} ${f.readers.length === 1 ? 'reads' : 'read'} ${f.objectType}, which the linked workspace isn't granted. A change request that creates ${f.objectType} grants it to the workspace; a Blueprint published by hand (\`frontera blueprint publish\`) grants nothing, and there ${f.readers.length === 1 ? 'it' : 'they'} can't read it`)
|
|
55
|
+
}
|