@frontera-sdk/cli 1.52.45 → 1.52.46
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
|
@@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { itemBuild } from '../../local/local-build-summary'
|
|
5
|
+
import { restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
|
|
5
6
|
import type { LocalProject } from '../../local/local-project'
|
|
6
7
|
import { providerRefusal } from '../../local/local-provider-refusal'
|
|
8
|
+
import { waitForDatabaseReady } from '../../local/local-runtime-health'
|
|
7
9
|
import { plainVariables, redactedVariablesOfTimeline, variablesOfTimeline } from '../../local/local-test-cases'
|
|
8
10
|
import { chatTarget, type ToolCall } from '../../local/local-test-runner'
|
|
9
11
|
import { dropTurn, readTurn, reattachTurn, startTurn, TURN_IDLE_MS, type HeldTurn, type PendingDecision, type TurnRead } from '../mcp-chat'
|
|
12
|
+
import { conversationTranscript, searchConversations } from '../mcp-conversations'
|
|
10
13
|
import { errorResult, intArg, jsonResult, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
|
|
11
14
|
import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
|
|
12
15
|
import { awaitTurnTrace, conversationTraces, getTrace, listTraces, proceduresOf, readTrace, replyCheckOf, replyChecksOfTrace, type ReplyCheck } from '../mcp-traces'
|
|
@@ -33,7 +36,7 @@ export interface SeenMessage {
|
|
|
33
36
|
*/
|
|
34
37
|
export interface McpMemory {
|
|
35
38
|
lastConversation: Map<string, { conversationId: string; agentId: string; at: string; lastTurn?: { reply: string; toolCalls: ToolCall[] } }>
|
|
36
|
-
conversations: Map<string, { agent: string; agentId: string; agentName: string; live: boolean; transcript: SeenMessage[]; realActions?: boolean; realActionsNote?: string | null; variables?: Record<string, unknown
|
|
39
|
+
conversations: Map<string, { agent: string; agentId: string; agentName: string; live: boolean; transcript: SeenMessage[]; realActions?: boolean; realActionsNote?: string | null; variables?: Record<string, unknown>; rollback?: { savedAt: string; rolledBack?: string } }>
|
|
37
40
|
waiting: Map<string, { held: HeldTurn; pending: PendingDecision; since: string; before: string[] }>
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -328,6 +331,14 @@ export async function caseReplyChecks(link: RuntimeLink, agentId: string | undef
|
|
|
328
331
|
return lastReplyChecks(timeline, []) ?? lastReplyChecks([], await conversationTraces(link, conversationId).catch(() => []))
|
|
329
332
|
}
|
|
330
333
|
|
|
334
|
+
/**
|
|
335
|
+
* The headline for a turn whose writes were simulated (Forge improvement plan
|
|
336
|
+
* N8): the Agent's reply may read as if a record changed, and it did not.
|
|
337
|
+
*/
|
|
338
|
+
export function simulatedWritesLine(count: number): string {
|
|
339
|
+
return `${count} write${count === 1 ? '' : 's'} simulated, not applied: nothing changed, whatever the reply says (realActions: true in a new conversation runs them for real)`
|
|
340
|
+
}
|
|
341
|
+
|
|
331
342
|
export function agentTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
332
343
|
/**
|
|
333
344
|
* The turn as the agent reads it: the reply so far, what the Agent waits
|
|
@@ -392,6 +403,8 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
392
403
|
agent: slug,
|
|
393
404
|
version: args.live ? 'live' : 'draft',
|
|
394
405
|
conversationId,
|
|
406
|
+
// First, so it is never missed: a write the draft only simulated changed nothing (N8).
|
|
407
|
+
...(!args.live && simulated.length ? { simulatedWrites: simulatedWritesLine(simulated.length) } : {}),
|
|
395
408
|
...(args.answered ? { answered: args.answered } : {}),
|
|
396
409
|
reply: read.reply,
|
|
397
410
|
...(read.end.status === 'waiting' ? { pendingDecision: pendingDecisionView(read.end.pending, conversationId), runId: read.runId } : {}),
|
|
@@ -409,6 +422,8 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
409
422
|
: 'Functions and Workflows that only read run for real; ones that change something are not run (realActions: true in a new conversation runs them)',
|
|
410
423
|
...(args.realActionsNote ? { realActionsNote: args.realActionsNote } : {}),
|
|
411
424
|
...(simulated.length ? { notRun: simulated } : {}),
|
|
425
|
+
// L2: the records were saved when it started; agent_rollback puts them back.
|
|
426
|
+
...(memory.conversations.get(args.conversationId)?.rollback && !memory.conversations.get(args.conversationId)!.rollback!.rolledBack ? { rollback: `records saved at ${memory.conversations.get(args.conversationId)!.rollback!.savedAt}: agent_rollback puts back what this conversation wrote` } : {}),
|
|
412
427
|
}),
|
|
413
428
|
...(model ? { model } : {}),
|
|
414
429
|
ms,
|
|
@@ -536,6 +551,10 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
536
551
|
additionalProperties: { type: ['string', 'number', 'boolean'] },
|
|
537
552
|
description: "The conversation's variables, by name ({ customer_id: 'CST-00060' }), as a channel would set them: what the instructions read as {{customer_id}} and what operating.json dataScopes narrow the Agent's reads by. Only when starting a conversation on the draft.",
|
|
538
553
|
},
|
|
554
|
+
rollback: {
|
|
555
|
+
type: 'boolean',
|
|
556
|
+
description: "With realActions, when starting: save the local runtime's records first, so agent_rollback puts back what this conversation's actions changed once you have read them (default false). It replaces the runtime's saved baseline, as `frontera local snapshot save` does.",
|
|
557
|
+
},
|
|
539
558
|
},
|
|
540
559
|
required: ['agent', 'message'],
|
|
541
560
|
additionalProperties: false,
|
|
@@ -553,6 +572,15 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
553
572
|
const variables = input.variables && typeof input.variables === 'object' && !Array.isArray(input.variables) && Object.keys(input.variables).length ? (input.variables as Record<string, unknown>) : undefined
|
|
554
573
|
if (variables && conversationId) return errorResult('variables are set when a conversation starts: leave out conversationId to start one with them.')
|
|
555
574
|
if (variables && live) return errorResult('variables are for the draft here: the published version gets them from its channel.')
|
|
575
|
+
// L2: a real-action conversation whose writes are put back afterwards, by agent_rollback.
|
|
576
|
+
const rollback = input.rollback === true
|
|
577
|
+
if (rollback && (conversationId || !realActions)) return errorResult('rollback is chosen when a conversation starts with realActions: true; it saves the records first, and agent_rollback puts them back.')
|
|
578
|
+
let savedAt: string | null = null
|
|
579
|
+
if (rollback) {
|
|
580
|
+
const saved = await saveBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, true)
|
|
581
|
+
if (!saved.ok) return errorResult(`The records could not be saved first, so the conversation was not started: ${saved.note}`)
|
|
582
|
+
savedAt = new Date().toISOString()
|
|
583
|
+
}
|
|
556
584
|
|
|
557
585
|
let target: { agentId: string; agentName: string }
|
|
558
586
|
let realActionsNote: string | null = null
|
|
@@ -591,7 +619,7 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
591
619
|
}
|
|
592
620
|
}
|
|
593
621
|
}
|
|
594
|
-
const remembered = memory.conversations.get(conversationId) ?? { agent: slug, agentId: target.agentId, agentName: target.agentName, live, transcript: [], realActions: realActions && !realActionsNote, realActionsNote, ...(variables ? { variables } : {}) }
|
|
622
|
+
const remembered = memory.conversations.get(conversationId) ?? { agent: slug, agentId: target.agentId, agentName: target.agentName, live, transcript: [], realActions: realActions && !realActionsNote, realActionsNote, ...(variables ? { variables } : {}), ...(savedAt ? { rollback: { savedAt } } : {}) }
|
|
595
623
|
memory.conversations.set(conversationId, remembered)
|
|
596
624
|
remembered.transcript.push({ role: 'user', parts: [{ type: 'text', text: message }] })
|
|
597
625
|
// The traces already there, so this turn's is the one that is new.
|
|
@@ -624,6 +652,29 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
624
652
|
return finishTurn({ conversationId, agentId: target.agentId, held, read, since, before, started, live, realActions, realActionsNote })
|
|
625
653
|
},
|
|
626
654
|
},
|
|
655
|
+
{
|
|
656
|
+
name: 'agent_rollback',
|
|
657
|
+
description:
|
|
658
|
+
"Put the local runtime's records back as they were when a conversation started with rollback: true (Forge improvement plan L2): what its real actions wrote, and anything else written since, is undone. The conversation itself, its traces and run history stay to read. Do it once you have checked what the actions did (blueprint_query).",
|
|
659
|
+
inputSchema: {
|
|
660
|
+
type: 'object',
|
|
661
|
+
properties: { conversationId: { type: 'string', description: 'The conversation agent_chat started with rollback: true.' } },
|
|
662
|
+
required: ['conversationId'],
|
|
663
|
+
additionalProperties: false,
|
|
664
|
+
},
|
|
665
|
+
annotations: { title: 'Put back what a conversation wrote', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
666
|
+
async run(input) {
|
|
667
|
+
const link = requireRuntime(project)
|
|
668
|
+
const conversationId = stringArg(input, 'conversationId', { required: true })
|
|
669
|
+
const known = memory.conversations.get(conversationId)
|
|
670
|
+
if (!known?.rollback) return errorResult('That conversation was not started with rollback: true in this session, so there is nothing saved for it. `frontera local snapshot restore` puts back the runtime\'s saved baseline, whenever it was taken.')
|
|
671
|
+
if (known.rollback.rolledBack) return errorResult(`Already put back, at ${known.rollback.rolledBack}.`)
|
|
672
|
+
const restored = await restoreBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, { secrets: 'keep', history: 'keep' })
|
|
673
|
+
if (!restored.ok) return errorResult(`The records could not be put back: ${restored.note}`)
|
|
674
|
+
known.rollback.rolledBack = new Date().toISOString()
|
|
675
|
+
return jsonResult({ conversationId, putBack: `the records as they were at ${known.rollback.savedAt}`, note: restored.note, kept: 'the conversation, its traces and run history' })
|
|
676
|
+
},
|
|
677
|
+
},
|
|
627
678
|
{
|
|
628
679
|
name: 'agent_decide',
|
|
629
680
|
description:
|
|
@@ -668,6 +719,47 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
|
|
|
668
719
|
return jsonResult(readTrace(link, trace, { detail: input.detail !== false }))
|
|
669
720
|
},
|
|
670
721
|
},
|
|
722
|
+
{
|
|
723
|
+
name: 'conversation_get',
|
|
724
|
+
description: "Read one conversation on the local runtime whole: every turn's message and reply, the tools each turn called (with their input, clipped), the procedure the turn ran under and why, what the reply check did, and each turn's traceId. A test case's conversationId, agent_chat's, or one conversation_search found. Use it instead of reading the runtime's database or asking the person to paste a transcript.",
|
|
725
|
+
inputSchema: {
|
|
726
|
+
type: 'object',
|
|
727
|
+
properties: {
|
|
728
|
+
conversationId: { type: 'string', description: 'The conversation (session) id.' },
|
|
729
|
+
traces: { type: 'boolean', description: 'Read each turn\'s trace for its procedure and reply check (default true).' },
|
|
730
|
+
},
|
|
731
|
+
required: ['conversationId'],
|
|
732
|
+
additionalProperties: false,
|
|
733
|
+
},
|
|
734
|
+
annotations: { title: 'Read a conversation', readOnlyHint: true, openWorldHint: false },
|
|
735
|
+
async run(input) {
|
|
736
|
+
const link = requireRuntime(project)
|
|
737
|
+
const id = stringArg(input, 'conversationId', { required: true })
|
|
738
|
+
const transcript = await conversationTranscript(link, id, { traces: input.traces !== false })
|
|
739
|
+
if (!transcript) return errorResult(`no conversation "${id}" on the local runtime; conversation_search finds them`)
|
|
740
|
+
return jsonResult(transcript)
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: 'conversation_search',
|
|
745
|
+
description: 'Find conversations on the local runtime, newest first: by words in them, by Agent, or only those test runs started. Returns each one\'s conversationId for conversation_get. The local runtime only: a workspace\'s conversations are not read from here.',
|
|
746
|
+
inputSchema: {
|
|
747
|
+
type: 'object',
|
|
748
|
+
properties: {
|
|
749
|
+
query: { type: 'string', description: 'Words the conversation contains.' },
|
|
750
|
+
agent: { type: 'string', description: 'Only this Agent\'s (its slug).' },
|
|
751
|
+
tests: { type: 'boolean', description: 'Only conversations test runs started.' },
|
|
752
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'At most this many (default 20).' },
|
|
753
|
+
},
|
|
754
|
+
additionalProperties: false,
|
|
755
|
+
},
|
|
756
|
+
annotations: { title: 'Find conversations', readOnlyHint: true, openWorldHint: false },
|
|
757
|
+
async run(input) {
|
|
758
|
+
const link = requireRuntime(project)
|
|
759
|
+
const hits = await searchConversations(link, { query: stringArg(input, 'query'), agent: stringArg(input, 'agent'), tests: input.tests === true, limit: intArg(input, 'limit', 20, 1, 100) })
|
|
760
|
+
return jsonResult({ conversations: hits })
|
|
761
|
+
},
|
|
762
|
+
},
|
|
671
763
|
{
|
|
672
764
|
name: 'trace_list',
|
|
673
765
|
description: 'Recent traces on the local runtime, newest first: Agent turns, workflow runs and Function calls. Filter by conversation, name or errors.',
|
|
@@ -3,7 +3,10 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { noticeLines, projectNotices } from '../../local/local-project-notices'
|
|
4
4
|
import { solutionStatus, type ItemBuild } from '../../local/local-applied'
|
|
5
5
|
import { applySummaryLine, inheritedRefsOf } from '../../local/local-apply-summary'
|
|
6
|
-
import { keylessEmbeddingsStatus } from '../../local/local-org-settings'
|
|
6
|
+
import { keylessEmbeddingsStatus, PLATFORM_DEFAULT_EMBEDDING } from '../../local/local-org-settings'
|
|
7
|
+
import { embeddingKeyOptions, embeddingNextStep } from '../../local/local-embedding-setup'
|
|
8
|
+
import { configuredProviders } from '../../local/local-keys'
|
|
9
|
+
import { readSnapshot } from '../../local/local-sync-snapshot'
|
|
7
10
|
import { localOrganizationId } from '../../local/local-blueprint-outline'
|
|
8
11
|
import { docsStatus } from '../../local/local-docs'
|
|
9
12
|
import { readConfig } from '../../local/local-env'
|
|
@@ -21,6 +24,9 @@ import { errorResult, intArg, jsonResult, stringArg, type McpTool } from '../mcp
|
|
|
21
24
|
import { runtimeLink, studioLink } from '../mcp-runtime'
|
|
22
25
|
import { savedObjectSets } from './workflow-tools'
|
|
23
26
|
import { listDevServers } from '../mcp-apps'
|
|
27
|
+
import { addPersonStep, checkProblem, CREDENTIALS, deliveryGroups, PERSON_STEPS_FILE, readPersonSteps, type StepCheck } from '../../local/local-person-steps'
|
|
28
|
+
import { readLock, recentWriters, writerOf, writerWords } from '../../local/local-writers'
|
|
29
|
+
import { FRONTERA_READ_ONLY_COMMANDS } from '../../forge/permission-rules'
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* Where the project stands: what the runtime made of each item (with why
|
|
@@ -64,7 +70,7 @@ export function projectTools(project: LocalProject): McpTool[] {
|
|
|
64
70
|
{
|
|
65
71
|
name: 'status',
|
|
66
72
|
description:
|
|
67
|
-
"Where the project stands on the local runtime: what each Agent, workflow, Function and Blueprint object type came to (built, adapted, stubbed, needs setup, blocked, failed, not applied) with the reason chain to the item actually at fault, files saved but not applied, the workspace link, the runtime's health and its documentation's version, the saved object sets with their ids (blueprint_query reads one), any knowledge document left pending or processing with no ingestion behind it (`knowledgeStuck`, each with the command that re-queues it), and each Agent, workflow, Function or knowledge base on the runtime with no file in the project (`notInProject`, each with its fix: apply archives an Agent or workflow whose file was deleted, and switches off such a Function). `build` counts exactly the rows `items` lists with all: true. Read it first after a change.",
|
|
73
|
+
"Where the project stands on the local runtime: who last changed it or its files (`writers`, one per session, `self` for yours; `runtimeLock` while another run holds the runtime), what each Agent, workflow, Function and Blueprint object type came to (built, adapted, stubbed, needs setup, blocked, failed, not applied) with the reason chain to the item actually at fault, files saved but not applied, the workspace link, the runtime's health and its documentation's version, the saved object sets with their ids (blueprint_query reads one), any knowledge document left pending or processing with no ingestion behind it (`knowledgeStuck`, each with the command that re-queues it), and each Agent, workflow, Function or knowledge base on the runtime with no file in the project (`notInProject`, each with its fix: apply archives an Agent or workflow whose file was deleted, and switches off such a Function). `build` counts exactly the rows `items` lists with all: true. Read it first after a change.",
|
|
68
74
|
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'List built items too, not only the ones with something to say (default false).' } }, additionalProperties: false },
|
|
69
75
|
annotations: { title: 'Project status', readOnlyHint: true, openWorldHint: false },
|
|
70
76
|
async run(input) {
|
|
@@ -93,7 +99,12 @@ export function projectTools(project: LocalProject): McpTool[] {
|
|
|
93
99
|
const sync = readLink(project.root)
|
|
94
100
|
// The saved object sets with their ids: status lists `objectSet/<file>` as built, and an App or a query addresses a set by id.
|
|
95
101
|
const objectSets = typeof link === 'string' ? null : await savedObjectSets(link, project.root).catch(() => null)
|
|
102
|
+
// Who last changed the runtime or the files (one per session, `self` for this session), and the run holding the runtime (X9).
|
|
103
|
+
const me = writerOf()
|
|
104
|
+
const lock = readLock(project.stateDir)
|
|
96
105
|
return jsonResult({
|
|
106
|
+
writers: recentWriters(project.stateDir).map((w) => ({ ...w, self: w.session !== null && w.session === me.session, said: writerWords(w, me) })),
|
|
107
|
+
...(lock ? { runtimeLock: { command: lock.command, session: lock.session, harness: lock.harness, since: lock.since, self: lock.session !== null && lock.session === me.session } } : {}),
|
|
97
108
|
runtime:
|
|
98
109
|
typeof link === 'string'
|
|
99
110
|
? { running: false, why: link }
|
|
@@ -128,8 +139,13 @@ export function projectTools(project: LocalProject): McpTool[] {
|
|
|
128
139
|
return orphans?.length ? { notInProject: orphans.map((o) => ({ ref: o.ref, problem: o.problem, fix: o.fix, fileDeleted: o.deleted })) } : {}
|
|
129
140
|
})()),
|
|
130
141
|
workspace: sync ? { linked: true, workspace: sync.workspaceName ?? sync.workspace, platform: sync.platform } : { linked: false },
|
|
142
|
+
// The steps prepared for the person and not yet run (person_step): the delivery card Forge shows.
|
|
143
|
+
...(() => {
|
|
144
|
+
const pending = readPersonSteps(project.root).filter((p) => p.status === 'pending')
|
|
145
|
+
return pending.length ? { personSteps: pending.map((p) => ({ id: p.id, command: p.command, credential: p.credential, ...(p.delivery ? { delivery: p.delivery } : {}) })) } : {}
|
|
146
|
+
})(),
|
|
131
147
|
// Knowledge search on the keyless local embeddings: approximate, with what gives the platform's.
|
|
132
|
-
...(state?.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: keylessEmbeddingsStatus() } : {}),
|
|
148
|
+
...(state?.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: { ...keylessEmbeddingsStatus(), next: embeddingNextStep(embeddingKeyOptions(process.env, configuredProviders(), readSnapshot(project.root)?.settings?.embedding ?? PLATFORM_DEFAULT_EMBEDDING), true) } } : {}),
|
|
133
149
|
// What a pull couldn't bring and why, App code on the platform, older sample data or vendored SDK: each with its command.
|
|
134
150
|
...(() => {
|
|
135
151
|
const all = projectNotices(project.root)
|
|
@@ -176,13 +192,129 @@ export function projectTools(project: LocalProject): McpTool[] {
|
|
|
176
192
|
}
|
|
177
193
|
},
|
|
178
194
|
},
|
|
195
|
+
{
|
|
196
|
+
name: 'agent_publish',
|
|
197
|
+
description:
|
|
198
|
+
"Publish an Agent's draft on the local runtime (`frontera local publish`), so its public website keys, Chat Apps and channels there talk to it. The local runtime only: the workspace's Agent is published by a person, through a change request.",
|
|
199
|
+
inputSchema: { type: 'object', properties: { agent: { type: 'string', description: 'The Agent\'s slug, its folder under agents/.' }, notes: { type: 'string', maxLength: 500, description: 'What the version changes.' } }, required: ['agent'], additionalProperties: false },
|
|
200
|
+
annotations: { title: 'Publish an Agent on the local runtime', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
201
|
+
async run(input) {
|
|
202
|
+
const link = runtimeLink(project)
|
|
203
|
+
if (typeof link === 'string') return errorResult(link)
|
|
204
|
+
const agent = stringArg(input, 'agent', { required: true })
|
|
205
|
+
const result = await runSelf(['local', 'publish', agent, ...(stringArg(input, 'notes') ? ['--notes', stringArg(input, 'notes')!] : [])], { cwd: project.root })
|
|
206
|
+
return result.ok ? jsonResult({ published: agent, result: result.data, studio: studioLink(link, '/agents') }) : errorResult(result.log || `Agent ${agent} could not be published on the local runtime.`)
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'agent_new',
|
|
211
|
+
description:
|
|
212
|
+
"Write a new Agent's files into the project (`frontera local new agent`): the starter (agent.json, bindings.json, a case; operating.json only with customer: true), or with like a copy of another Agent's files under the new name. Writes files only, never over one that is there; apply then applies them.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
slug: { type: 'string', description: 'The new Agent\'s slug, its folder under agents/: lower-case letters, digits and hyphens.' },
|
|
217
|
+
like: { type: 'string', description: 'Start from this Agent\'s files, by its slug.' },
|
|
218
|
+
name: { type: 'string', description: 'Its display name; defaults to the slug in words.' },
|
|
219
|
+
description: { type: 'string' },
|
|
220
|
+
model: { type: 'string', description: 'The model it runs on, by catalog slug.' },
|
|
221
|
+
customer: { type: 'boolean', description: 'It serves customers: write behavior settings (operating.json).' },
|
|
222
|
+
},
|
|
223
|
+
required: ['slug'],
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
},
|
|
226
|
+
annotations: { title: 'Start a new Agent', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
227
|
+
async run(input) {
|
|
228
|
+
const slug = stringArg(input, 'slug', { required: true })
|
|
229
|
+
const flags = (['like', 'name', 'description', 'model'] as const).flatMap((k) => (stringArg(input, k) ? [`--${k}`, stringArg(input, k)!] : []))
|
|
230
|
+
const result = await runSelf(['local', 'new', 'agent', slug, ...flags, ...(input.customer === true ? ['--customer'] : [])], { cwd: project.root })
|
|
231
|
+
return result.ok ? jsonResult(result.data) : errorResult(result.log || `agents/${slug}/ could not be written.`)
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'format_files',
|
|
236
|
+
description:
|
|
237
|
+
"Write the project's JSON and YAML files in one shape (`frontera local fmt`): two-space indents, a final newline, comments kept. check: true writes nothing and lists what would change. Names a file it can't read, with the line and column.",
|
|
238
|
+
inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'Only these files or folders, from the project root.' }, check: { type: 'boolean' } }, additionalProperties: false },
|
|
239
|
+
annotations: { title: 'Format project files', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
240
|
+
async run(input) {
|
|
241
|
+
const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === 'string') : []
|
|
242
|
+
const result = await runSelf(['local', 'fmt', ...paths, ...(input.check === true ? ['--check'] : [])], { cwd: project.root })
|
|
243
|
+
return result.ok ? jsonResult(result.data) : errorResult(result.log || 'The files could not be formatted.')
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'data_load',
|
|
248
|
+
description:
|
|
249
|
+
"Load records through the project's governed Actions on the local runtime (`frontera local data load <file> --via-actions`): a JSON or YAML file of { action, records } groups, run in its order, each record submitted as the local submitter persona. The records are saved first and put back when one is refused; loading the same file again submits nothing twice. Returns each group's applied, waiting, not applied and refused counts.",
|
|
250
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'The file, by its path from the project root.' }, batch: { type: 'integer', minimum: 1, maximum: 16, description: 'Records submitted at once (default 4).' } }, required: ['file'], additionalProperties: false },
|
|
251
|
+
annotations: { title: 'Load records through governed Actions', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
252
|
+
async run(input) {
|
|
253
|
+
const link = runtimeLink(project)
|
|
254
|
+
if (typeof link === 'string') return errorResult(link)
|
|
255
|
+
const file = stringArg(input, 'file', { required: true })
|
|
256
|
+
const batch = typeof input.batch === 'number' ? ['--batch', String(input.batch)] : []
|
|
257
|
+
const result = await runSelf(['local', 'data', 'load', file, '--via-actions', ...batch], { cwd: project.root, timeoutMs: 15 * 60_000 })
|
|
258
|
+
return result.ok ? jsonResult({ ok: true, result: result.data, log: result.log }) : { isError: true as const, content: [{ type: 'text', text: JSON.stringify({ ok: false, result: result.data, log: result.log }, null, 2) }] }
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'person_step',
|
|
263
|
+
description:
|
|
264
|
+
"Prepare a step only the person takes (publish, promote, apply a change request, decide or review an Action, grant, set a secret) for them to run, instead of saying what is ready or asking them to paste output back. Records the `frontera` command, why it is needed, what it changes and the credential it needs in .frontera/sync/person-steps.json; Forge shows the pending steps as one delivery card per `delivery`, in order, and Run in my terminal types the command into the person's shell without pressing Enter. Nothing runs it but the person. One command on one line, no chaining, never a secret (a value is piped in by the person with `--from -`). Prepare the whole delivery's steps once, in order, rather than one per message; the same command again in the same delivery updates its step.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: 'object',
|
|
267
|
+
properties: {
|
|
268
|
+
command: { type: 'string', description: 'One `frontera` command, as the person runs it: `frontera local sync request apply`.' },
|
|
269
|
+
why: { type: 'string', description: 'Why it is needed now, in a sentence.' },
|
|
270
|
+
changes: { type: 'string', description: 'What it changes, in the workspace’s words: "Publishes Agent sdr in Sales".' },
|
|
271
|
+
credential: { type: 'string', enum: [...CREDENTIALS], description: 'Who or what the step needs.' },
|
|
272
|
+
delivery: { type: 'string', description: 'The delivery it belongs to (a change request’s title): steps with the same one show as one card.' },
|
|
273
|
+
check: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
description: 'Optional: the read that proves it done, so it checks itself. A read-only `frontera … --json` command, the list in its data (`in`, a property name), the row whose fields equal `find`, and the fields that equal `expect` once done: {command: "frontera action list --json", in: "actions", find: {apiName: "qualifyLead"}, expect: {available: true}}.',
|
|
276
|
+
properties: { command: { type: 'string' }, in: { type: 'string' }, find: { type: 'object' }, expect: { type: 'object' } },
|
|
277
|
+
required: ['command', 'find', 'expect'],
|
|
278
|
+
additionalProperties: false,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
required: ['command', 'why', 'changes', 'credential'],
|
|
282
|
+
additionalProperties: false,
|
|
283
|
+
},
|
|
284
|
+
annotations: { title: 'Prepare a person-step', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
285
|
+
async run(input) {
|
|
286
|
+
try {
|
|
287
|
+
const check = input.check && typeof input.check === 'object' ? (input.check as StepCheck) : undefined
|
|
288
|
+
const checkWrong = check ? checkProblem(check, FRONTERA_READ_ONLY_COMMANDS) : null
|
|
289
|
+
if (checkWrong) return errorResult(`Not recorded: the check: ${checkWrong}`)
|
|
290
|
+
const { step, updated } = addPersonStep(project.root, {
|
|
291
|
+
command: stringArg(input, 'command') ?? '',
|
|
292
|
+
why: stringArg(input, 'why') ?? '',
|
|
293
|
+
changes: stringArg(input, 'changes') ?? '',
|
|
294
|
+
credential: stringArg(input, 'credential') ?? '',
|
|
295
|
+
delivery: stringArg(input, 'delivery') ?? null,
|
|
296
|
+
...(check ? { check } : {}),
|
|
297
|
+
})
|
|
298
|
+
const card = deliveryGroups(readPersonSteps(project.root)).find((g) => g.delivery === step.delivery)
|
|
299
|
+
return jsonResult({
|
|
300
|
+
recorded: updated ? 'updated' : 'added',
|
|
301
|
+
step,
|
|
302
|
+
onCard: card?.steps.map((s) => s.command) ?? [step.command],
|
|
303
|
+
file: PERSON_STEPS_FILE,
|
|
304
|
+
next: 'Tell the person the delivery card is ready in Forge (or `frontera local steps`). Do not run the step yourself; check for done afterwards with a read (status, list, get).',
|
|
305
|
+
})
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return errorResult(`Not recorded: ${(err as Error).message}`)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
},
|
|
179
311
|
{
|
|
180
312
|
name: 'logs',
|
|
181
|
-
description: "The end of a local runtime component's log (service by default), optionally only lines containing some text. Use it when a tool or apply reports a server error.",
|
|
313
|
+
description: "The end of a local runtime component's log (service by default), or of every component's (all), optionally only lines containing some text. Use it when a tool or apply reports a server error; all when you don't know which component it came from.",
|
|
182
314
|
inputSchema: {
|
|
183
315
|
type: 'object',
|
|
184
316
|
properties: {
|
|
185
|
-
component: { type: 'string', enum: COMPONENTS, description: 'Which component (default service).' },
|
|
317
|
+
component: { type: 'string', enum: [...COMPONENTS, 'all'], description: 'Which component (default service), or all: every component\'s lines, each marked with its component, in the order they were written.' },
|
|
186
318
|
lines: { type: 'integer', minimum: 1, maximum: 1000, description: 'At most this many lines (default 120).' },
|
|
187
319
|
contains: { type: 'string', description: 'Only lines containing this text, case-insensitive.' },
|
|
188
320
|
},
|
|
@@ -190,15 +322,19 @@ export function projectTools(project: LocalProject): McpTool[] {
|
|
|
190
322
|
},
|
|
191
323
|
annotations: { title: 'Runtime logs', readOnlyHint: true, openWorldHint: false },
|
|
192
324
|
async run(input) {
|
|
193
|
-
const
|
|
194
|
-
if (!COMPONENTS.includes(
|
|
195
|
-
const
|
|
196
|
-
if (!existsSync(file)) return errorResult(`No ${component} log yet: the runtime has not run here.`)
|
|
325
|
+
const asked = stringArg(input, 'component') ?? 'service'
|
|
326
|
+
if (asked !== 'all' && !COMPONENTS.includes(asked as ComponentName)) return errorResult(`unknown component "${asked}"; use one of ${COMPONENTS.join(', ')}, or all`)
|
|
327
|
+
const components = asked === 'all' ? COMPONENTS : [asked as ComponentName]
|
|
197
328
|
const needle = stringArg(input, 'contains')?.toLowerCase()
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
329
|
+
const present = components.filter((c) => existsSync(logPath(project, c)))
|
|
330
|
+
if (!present.length) return errorResult(`No ${asked === 'all' ? '' : `${asked} `}log yet: the runtime has not run here.`)
|
|
331
|
+
// Every component's lines, marked, kept in the order each was written; across files, by the time a line starts with.
|
|
332
|
+
const all = present.flatMap((c) => readFileSync(logPath(project, c), 'utf8').split('\n').filter(Boolean).map((line, i) => ({ c, line, i })))
|
|
333
|
+
const stamp = (line: string) => Date.parse(/^\[?(\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?)/.exec(line)?.[1] ?? '') || null
|
|
334
|
+
const ordered = components.length > 1 ? [...all].sort((a, b) => (stamp(a.line) ?? 0) - (stamp(b.line) ?? 0) || (a.c === b.c ? a.i - b.i : 0)) : all
|
|
335
|
+
const matching = needle ? ordered.filter((l) => l.line.toLowerCase().includes(needle)) : ordered
|
|
336
|
+
const lines = matching.slice(-intArg(input, 'lines', 120, 1, 1000)).map((l) => (components.length > 1 ? `[${l.c}] ${l.line}` : l.line))
|
|
337
|
+
return { content: [{ type: 'text', text: lines.length ? lines.join('\n') : `No ${asked === 'all' ? '' : `${asked} `}log lines${needle ? ` contain "${needle}"` : ''}.` }] }
|
|
202
338
|
},
|
|
203
339
|
},
|
|
204
340
|
]
|
|
@@ -5,9 +5,12 @@ import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
|
|
|
5
5
|
import type { LocalProject } from '../../local/local-project'
|
|
6
6
|
import { compareWithLastRun, readLastRun, recordRun } from '../../local/local-test-baseline'
|
|
7
7
|
import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, renderTestCase, type SavedDecision } from '../../local/local-test-cases'
|
|
8
|
+
import { compareWithRun, comparisonWords, listTestRuns, readTestRun, runOnModel, saveTestRun, stuckWords } from '../../local/local-test-models'
|
|
8
9
|
import { agentFilesProblem, caseCount, judge, runCases, selectCaseFiles, usesRealActions, waitedWords, type CaseFile, type CaseResult, type TestCase, type TestCheck, type ToolCall } from '../../local/local-test-runner'
|
|
9
10
|
import { errorResult, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
|
|
10
11
|
import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
|
|
12
|
+
import { conversationTranscript } from '../mcp-conversations'
|
|
13
|
+
import { modelSubstitutions, skippedCases, skippedWords } from '../../local/local-test-scope'
|
|
11
14
|
import { caseReplyChecks, conversationRunsRealActions, conversationVariables, goneConversation, type McpMemory } from './agent-tools'
|
|
12
15
|
|
|
13
16
|
/** The conversation's stored messages, or null when the runtime no longer has it. */
|
|
@@ -43,6 +46,11 @@ function caseLine(link: RuntimeLink, r: CaseResult, files: CaseFile[] = [], repl
|
|
|
43
46
|
...(r.retried ? { retried: `the provider refused the first try for its limits, so it ran again after a pause: ${r.retried}` } : {}),
|
|
44
47
|
// A case that keeps a gap between turns (`waitSeconds`) says so: it took that long on purpose.
|
|
45
48
|
...(r.waited?.length ? { waited: waitedWords(r.waited) } : {}),
|
|
49
|
+
// `repeat`: how many of its runs passed; one that passed some is flaky (X1).
|
|
50
|
+
...(r.repeat ? { runs: r.repeat.runs, passes: r.repeat.passes, passRate: r.repeat.passRate, ...(r.repeat.flaky ? { flaky: true } : {}) } : {}),
|
|
51
|
+
// Its `judged` expectations, graded by the runtime's judge, or why they weren't.
|
|
52
|
+
...(r.judged?.length ? { judged: r.judged } : {}),
|
|
53
|
+
...(r.judgedNotGraded ? { judgedNotGraded: r.judgedNotGraded } : {}),
|
|
46
54
|
// A failing case whose reply the reply check rewrote or replaced: which check, what it found, both drafts.
|
|
47
55
|
...(replyCheck ? { replyCheck } : {}),
|
|
48
56
|
ms: r.ms,
|
|
@@ -103,7 +111,7 @@ async function conversationDecision(link: RuntimeLink, conversationId: string):
|
|
|
103
111
|
return decisionOfApprovals(Array.isArray(cards) ? cards : [])
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
const CHECK_TYPES = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
114
|
+
const CHECK_TYPES = new Set(['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
107
115
|
|
|
108
116
|
/** The checks as given, refused in words when one is not a check the runner grades. */
|
|
109
117
|
export function readChecks(raw: unknown): TestCheck[] {
|
|
@@ -112,12 +120,12 @@ export function readChecks(raw: unknown): TestCheck[] {
|
|
|
112
120
|
return raw.map((c, i) => {
|
|
113
121
|
const check = (c && typeof c === 'object' ? c : {}) as Record<string, unknown>
|
|
114
122
|
const at = `checks[${i}]`
|
|
115
|
-
if (typeof check.type !== 'string' || !CHECK_TYPES.has(check.type)) throw new Error(`${at}.type must be contains, regex, tool_called or tool_not_called`)
|
|
116
|
-
if ((check.type === 'contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) throw new Error(`${at} (${check.type}) needs a value`)
|
|
123
|
+
if (typeof check.type !== 'string' || !CHECK_TYPES.has(check.type)) throw new Error(`${at}.type must be contains, not_contains, regex, tool_called or tool_not_called`)
|
|
124
|
+
if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) throw new Error(`${at} (${check.type}) needs a value`)
|
|
117
125
|
if ((check.type === 'tool_called' || check.type === 'tool_not_called') && (typeof check.tool !== 'string' || !check.tool)) throw new Error(`${at} (${check.type}) needs a tool`)
|
|
118
126
|
if (check.argsSubset !== undefined && (typeof check.argsSubset !== 'object' || check.argsSubset === null || Array.isArray(check.argsSubset))) throw new Error(`${at}.argsSubset must be an object`)
|
|
119
127
|
if (check.mode !== undefined && !['exact', 'ignore', 'subset', 'superset'].includes(String(check.mode))) throw new Error(`${at}.mode must be exact, ignore, subset or superset`)
|
|
120
|
-
if (check.turn !== undefined && check.turn
|
|
128
|
+
if (check.turn !== undefined && !['all', 'any', 'last'].includes(String(check.turn)) && !(typeof check.turn === 'number' && Number.isInteger(check.turn) && check.turn >= 1)) throw new Error(`${at}.turn must be a turn number from 1, last, any or all`)
|
|
121
129
|
return check as unknown as TestCheck
|
|
122
130
|
})
|
|
123
131
|
}
|
|
@@ -165,7 +173,7 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
|
165
173
|
{
|
|
166
174
|
name: 'test_run',
|
|
167
175
|
description:
|
|
168
|
-
"Run the project's saved Agent test cases (agents/<slug>/evals/*.yaml) against the local runtime. Scope it to the Agent or file you changed. Returns each case's outcome and the cases that passed at the last run and fail now (newlyFailing): those are what your change broke. Cases the model provider refused (credits, a rate limit, a key), that timed out, or that the runtime did not answer are couldNotRun, with the cause and the fix: not a regression and not newly failing, but the run has not passed; tell the person the fix (keys and provider accounts are theirs). After a refusal every case would meet (no credit, a refused key), the cases not sent yet are not sent. A failing case whose reply the reply check rewrote or replaced carries replyCheck: which check fired, what it found, what the person got and both drafts. A case whose turns wait (`waitSeconds` on a user turn, up to 300 each) takes that long, and says so in `waited`. Each case names its conversationId and its last turn's traceId (trace_get reads it).",
|
|
176
|
+
"Run the project's saved Agent test cases (agents/<slug>/evals/*.yaml) against the local runtime. Scope it to the Agent or file you changed. Returns each case's outcome and the cases that passed at the last run and fail now (newlyFailing): those are what your change broke. Cases the model provider refused (credits, a rate limit, a key), that timed out, or that the runtime did not answer are couldNotRun, with the cause and the fix: not a regression and not newly failing, but the run has not passed; tell the person the fix (keys and provider accounts are theirs). After a refusal every case would meet (no credit, a refused key), the cases not sent yet are not sent. A failing case whose reply the reply check rewrote or replaced carries replyCheck: which check fired, what it found, what the person got and both drafts. A case whose turns wait (`waitSeconds` on a user turn, up to 300 each) takes that long, and says so in `waited`. Each case names its conversationId and its last turn's traceId (trace_get reads it). A case's judged expectations are graded by the runtime's expectation judge on its grading model; judgedNotGraded says why when they couldn't be. To tell a flaky case from a regression, run once with repeat (e.g. 5): each case says how many runs passed, and one that passed some is flaky. skipped says what the scope left out and why; modelSubstitutions names Agents running on another model here.",
|
|
169
177
|
inputSchema: {
|
|
170
178
|
type: 'object',
|
|
171
179
|
properties: {
|
|
@@ -175,6 +183,11 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
|
175
183
|
regression: { type: 'boolean', description: 'Only the regression datasets the workspace tests before publishing.' },
|
|
176
184
|
gate: { type: 'boolean', description: 'Only the cases marked gate: true: what a change request runs before it applies.' },
|
|
177
185
|
concurrency: { type: 'integer', minimum: 1, maximum: 16, description: 'How many cases run at once (default 2). Cases that change records for real still run one at a time. Lower it to 1 when the model provider refuses for requests in flight.' },
|
|
186
|
+
transcripts: { type: 'boolean', description: 'Include each case\'s whole conversation: every turn\'s message, reply and tool calls, the procedure it ran under and what the reply check did (default false). Use it instead of reading the runtime\'s database.' },
|
|
187
|
+
repeat: { type: 'integer', minimum: 1, maximum: 10, description: 'Run each conversation case this many times (default 1) and report its pass rate. A case that passes some runs and fails others is flaky: it passes at passRate or above. Use it to tell a flaky case from a regression in one run, instead of running again.' },
|
|
188
|
+
passRate: { type: 'number', exclusiveMinimum: 0, maximum: 1, description: 'With repeat: the share of runs a flaky case must pass (default 0.8).' },
|
|
189
|
+
model: { type: 'string', description: 'Run the cases with each Agent on this catalog model instead of its own: its draft is put on it for the run and back after; the files never change. Compare models with baseline.' },
|
|
190
|
+
baseline: { type: 'string', description: 'Compare with a kept run: last, last-own (the newest on the Agents\' own models), last-<model>, or a run id. Every run is kept; the answer names its run.' },
|
|
178
191
|
},
|
|
179
192
|
additionalProperties: false,
|
|
180
193
|
},
|
|
@@ -197,14 +210,47 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
|
197
210
|
const last = readLastRun(project)
|
|
198
211
|
// A draft that isn't what the Agent's files say fails its cases with what to run, as `local test` does.
|
|
199
212
|
const concurrency = typeof input.concurrency === 'number' && Number.isInteger(input.concurrency) && input.concurrency >= 1 && input.concurrency <= 16 ? input.concurrency : undefined
|
|
200
|
-
const
|
|
213
|
+
const repeat = typeof input.repeat === 'number' && Number.isInteger(input.repeat) && input.repeat >= 1 && input.repeat <= 10 ? input.repeat : undefined
|
|
214
|
+
const passRate = typeof input.passRate === 'number' && input.passRate > 0 && input.passRate <= 1 ? input.passRate : undefined
|
|
215
|
+
// L1: another model for the run, and a kept run to compare with.
|
|
216
|
+
const model = stringArg(input, 'model')?.trim() || null
|
|
217
|
+
if (model && input.live === true) return errorResult('model runs the drafts on another model; live tests the published versions as they are. Leave one out.')
|
|
218
|
+
const baselineRef = stringArg(input, 'baseline')?.trim()
|
|
219
|
+
const baseline = baselineRef ? readTestRun(project, baselineRef) : null
|
|
220
|
+
if (baselineRef && !baseline) return errorResult(`No kept test run ${baselineRef}: give last, last-own, last-<model> or one of ${listTestRuns(project).slice(-5).join(', ') || '(none yet: run once first)'}.`)
|
|
221
|
+
const runAll = () => runCases({ serviceUrl: link.serviceUrl, creds: link.creds, files, live: input.live === true, concurrency, ...(repeat ? { repeat } : {}), ...(passRate ? { passRate } : {}), staleDraft: (slug) => agentFilesProblem(project, link.creds.organizationId, slug), onResult: () => {} })
|
|
222
|
+
let onModel: { swapped: string[]; unchanged: string[]; stuck: string[] } | null = null
|
|
223
|
+
let results: Awaited<ReturnType<typeof runAll>>
|
|
224
|
+
if (model) {
|
|
225
|
+
try {
|
|
226
|
+
const ran = await runOnModel(project, link, files.map((f) => f.agent), model, () => runAll())
|
|
227
|
+
onModel = ran
|
|
228
|
+
results = ran.value
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return errorResult((err as Error).message)
|
|
231
|
+
}
|
|
232
|
+
} else results = await runAll()
|
|
233
|
+
const kept = saveTestRun(project, results, model)
|
|
234
|
+
const against = baseline ? compareWithRun(results, baseline) : null
|
|
235
|
+
// What the run left out and why, and Agents on another model here than their files name (X1).
|
|
236
|
+
const skipped = skippedCases(project.root, { ...(stringArg(input, 'agent') ? { agents: [stringArg(input, 'agent')!] } : {}), ...(file ? { file } : {}), regression: input.regression === true, gate: input.gate === true }).filter((x) => !/other Agents/.test(x.reason))
|
|
237
|
+
const models = modelSubstitutions(project, link.creds.organizationId, results.map((r) => r.agent))
|
|
238
|
+
const flaky = results.filter((r) => r.repeat?.flaky)
|
|
201
239
|
const comparison = compareWithLastRun(results, last)
|
|
202
|
-
|
|
240
|
+
// Another model's run is not the Agents' last run.
|
|
241
|
+
if (!model) recordRun(project, results)
|
|
203
242
|
const failed = results.filter((r) => !r.passed && !r.notRun)
|
|
204
243
|
const passed = results.filter((r) => r.passed).length
|
|
205
244
|
const notRun = notRunGroups(results)
|
|
206
245
|
return jsonResult({
|
|
207
|
-
summary: `${passed} passed, ${failed.length} failed${comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing` : ''}${notRun.length ? `, ${notRunWords(notRun)}` : ''}`,
|
|
246
|
+
summary: `${passed} passed${flaky.length ? ` (${flaky.filter((r) => r.passed).length} flaky)` : ''}, ${failed.length} failed${comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing` : ''}${notRun.length ? `, ${notRunWords(notRun)}` : ''}${repeat && repeat > 1 ? `; each case run ${repeat} times` : ''}${skippedWords(skipped) ? `; ${skippedWords(skipped)}` : ''}`,
|
|
247
|
+
run: kept.id,
|
|
248
|
+
...(model && onModel ? { model, onModel: onModel.swapped, ownModel: onModel.unchanged, ...(onModel.stuck.length ? { stillOnModel: stuckWords(onModel.stuck, model) } : {}) } : {}),
|
|
249
|
+
...(against && baseline ? { baseline: { run: baseline.id, model: baseline.model, said: comparisonWords(against, baseline), newlyFailing: against.newlyFailing.map(label), fixed: against.fixed.map(label), stillFailing: against.stillFailing.map(label) } } : {}),
|
|
250
|
+
...(flaky.length ? { flaky: flaky.map(label) } : {}),
|
|
251
|
+
...(skipped.length ? { skipped } : {}),
|
|
252
|
+
// The Agent ran on another model here: a pass or failure says less about the workspace's model.
|
|
253
|
+
...(models.length ? { modelSubstitutions: models } : {}),
|
|
208
254
|
newlyFailing: comparison.newlyFailing.map(label),
|
|
209
255
|
fixed: comparison.fixed.map(label),
|
|
210
256
|
stillFailing: comparison.stillFailing.map(label),
|
|
@@ -213,7 +259,12 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
|
213
259
|
...(notRun.length ? { couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}),
|
|
214
260
|
cases: await (async () => {
|
|
215
261
|
const traces = await caseTraces(project, results)
|
|
216
|
-
return Promise.all(results.map(async (r, i) =>
|
|
262
|
+
return Promise.all(results.map(async (r, i) => {
|
|
263
|
+
const line = caseLine(link, r, files, r.passed || r.notRun ? null : await caseReplyChecks(link, r.agentId, r.conversationId).catch(() => null), traces.get(i))
|
|
264
|
+
if (input.transcripts !== true || !r.conversationId) return line
|
|
265
|
+
const transcript = await conversationTranscript(link, r.conversationId).catch(() => null)
|
|
266
|
+
return transcript ? { ...line, transcript: transcript.turns } : line
|
|
267
|
+
}))
|
|
217
268
|
})(),
|
|
218
269
|
})
|
|
219
270
|
},
|
|
@@ -235,13 +286,13 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
|
235
286
|
items: {
|
|
236
287
|
type: 'object',
|
|
237
288
|
properties: {
|
|
238
|
-
type: { type: 'string', enum: ['contains', 'regex', 'tool_called', 'tool_not_called'] },
|
|
239
|
-
value: { type: 'string', description: 'contains, regex: the text or pattern.' },
|
|
289
|
+
type: { type: 'string', enum: ['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'] },
|
|
290
|
+
value: { type: 'string', description: 'contains, not_contains, regex: the text or pattern.' },
|
|
240
291
|
flags: { type: 'string', description: 'regex: its flags (default i).' },
|
|
241
292
|
tool: { type: 'string', description: 'tool_called, tool_not_called: the tool\'s name as the trace shows it (a Plugin\'s tool by its capability name, mcp__…, never its bare name).' },
|
|
242
|
-
argsSubset: { type: 'object', description: 'tool_called: arguments the call must carry.' },
|
|
293
|
+
argsSubset: { type: 'object', description: 'tool_called: arguments the call must carry; { absent: true } as a value says that argument must not be there.' },
|
|
243
294
|
mode: { type: 'string', enum: ['exact', 'ignore', 'subset', 'superset'], description: 'How argsSubset is compared (default superset).' },
|
|
244
|
-
turn: { type: 'string', enum: ['all', 'last'], description: '
|
|
295
|
+
turn: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string', enum: ['all', 'any', 'last'] }], description: 'Which turn the check reads: a reply check the last reply (default), turn n, any or all; a tool check every turn\'s calls (all, the default), the last turn\'s or turn n\'s.' },
|
|
245
296
|
text: { type: 'string', description: 'What the check means, in words, for its failure message.' },
|
|
246
297
|
},
|
|
247
298
|
required: ['type'],
|
package/src/output.ts
CHANGED
|
@@ -76,18 +76,29 @@ const defaultStreams: Streams = {
|
|
|
76
76
|
*/
|
|
77
77
|
export function createOutput(
|
|
78
78
|
mode: OutputMode,
|
|
79
|
-
opts: { quiet?: boolean; streams?: Streams } = {},
|
|
79
|
+
opts: { quiet?: boolean; streams?: Streams; envelope?: boolean } = {},
|
|
80
80
|
): Output {
|
|
81
81
|
const streams = opts.streams ?? defaultStreams
|
|
82
82
|
const quiet = opts.quiet ?? false
|
|
83
|
+
// `--envelope` (Forge improvement plan X10): one shape for every command,
|
|
84
|
+
// `{ ok, data, warnings }`, and a failure on stdout as `{ ok: false, error,
|
|
85
|
+
// warnings }`, so a caller reads one stream and one document whatever
|
|
86
|
+
// happened. Plain `--json` keeps the contract above.
|
|
87
|
+
const envelope = mode === 'json' && opts.envelope === true
|
|
88
|
+
const warnings: string[] = []
|
|
83
89
|
|
|
84
90
|
return {
|
|
85
91
|
render(data, toText) {
|
|
86
|
-
const text = mode === 'json' ? JSON.stringify(data, null, 2) : toText(data)
|
|
92
|
+
const text = envelope ? JSON.stringify({ ok: true, data, warnings }, null, 2) : mode === 'json' ? JSON.stringify(data, null, 2) : toText(data)
|
|
87
93
|
streams.out(`${text}\n`)
|
|
88
94
|
},
|
|
89
95
|
|
|
90
96
|
note(message) {
|
|
97
|
+
// A warning the command said is the envelope's too, quiet or not.
|
|
98
|
+
if (envelope) for (const line of message.split('\n')) {
|
|
99
|
+
const m = /^\s*warning:\s*(.+)$/i.exec(line)
|
|
100
|
+
if (m) warnings.push(m[1]!)
|
|
101
|
+
}
|
|
91
102
|
if (quiet) return
|
|
92
103
|
streams.err(`${message}\n`)
|
|
93
104
|
},
|
|
@@ -139,14 +150,18 @@ export function createOutput(
|
|
|
139
150
|
},
|
|
140
151
|
|
|
141
152
|
fail(err) {
|
|
142
|
-
const
|
|
153
|
+
const failure = toEnvelope(err)
|
|
154
|
+
if (envelope) {
|
|
155
|
+
streams.out(`${JSON.stringify({ ok: false, error: failure, warnings }, null, 2)}\n`)
|
|
156
|
+
return exitCodeFor(failure.code, failure.details)
|
|
157
|
+
}
|
|
143
158
|
if (mode === 'json') {
|
|
144
|
-
streams.err(`${JSON.stringify(
|
|
159
|
+
streams.err(`${JSON.stringify(failure, null, 2)}\n`)
|
|
145
160
|
} else {
|
|
146
|
-
streams.err(`error [${
|
|
147
|
-
if (
|
|
161
|
+
streams.err(`error [${failure.code}]: ${failure.message}\n`)
|
|
162
|
+
if (failure.hint) streams.err(` ${failure.hint}\n`)
|
|
148
163
|
}
|
|
149
|
-
return exitCodeFor(
|
|
164
|
+
return exitCodeFor(failure.code, failure.details)
|
|
150
165
|
},
|
|
151
166
|
}
|
|
152
167
|
}
|