@frontera-sdk/cli 1.52.39 → 1.52.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import { issueLines } from '../api/governed-action-api'
|
|
3
4
|
import { deriveOntologyEditPlan, PlanDerivationError, type ObjectTypeShape, type PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
|
|
4
5
|
import type { ActionFit } from './local-portability'
|
|
5
6
|
import type { LocalCredentials } from './local-state'
|
|
@@ -71,13 +72,37 @@ function data<T>(answer: Answer): T {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function message(answer: Answer): string {
|
|
74
|
-
const b = answer.body as { message?: unknown; error?: unknown } | null
|
|
75
|
+
const b = answer.body as { message?: unknown; error?: unknown; details?: unknown } | null
|
|
75
76
|
const text = typeof b?.message === 'string' ? b.message : typeof b === 'string' ? b : JSON.stringify(b)
|
|
76
|
-
|
|
77
|
+
// The reason a generic refusal ("Mutation Plan is invalid.") keeps in its issues.
|
|
78
|
+
const issues = b && typeof b === 'object' ? issueLines(b.details) : []
|
|
79
|
+
return `${answer.status} ${String(text).replace(/\s+/g, ' ').slice(0, 240)}${issues.length ? ` ${issues.join('; ').slice(0, 400)}` : ''}`
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
const ADMIN = '/v1/blueprint/governed-actions/admin'
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A validation report's failed findings, one line each, as the deployment
|
|
86
|
+
* router returns them (`findings: [{ source, code, passed, message }]`): the
|
|
87
|
+
* code and the platform's message. Empty when the report carries no such list
|
|
88
|
+
* (an older runtime), so the caller falls back to `failedChecks`.
|
|
89
|
+
*/
|
|
90
|
+
export function validationFindings(report: unknown): string[] {
|
|
91
|
+
const findings = (report as { findings?: unknown } | null)?.findings
|
|
92
|
+
if (!Array.isArray(findings)) return []
|
|
93
|
+
return [
|
|
94
|
+
...new Set(
|
|
95
|
+
findings.flatMap((f) => {
|
|
96
|
+
if (!f || typeof f !== 'object') return []
|
|
97
|
+
const o = f as { code?: unknown; passed?: unknown; message?: unknown }
|
|
98
|
+
if (o.passed !== false) return []
|
|
99
|
+
const code = typeof o.code === 'string' ? o.code : 'check'
|
|
100
|
+
return [typeof o.message === 'string' && o.message ? `${code}: ${o.message}` : code]
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
/** The checks a validation report failed, by name: its findings with `passed: false`, anywhere in it. */
|
|
82
107
|
function failedChecks(report: unknown): string {
|
|
83
108
|
const names: string[] = []
|
|
@@ -200,16 +225,33 @@ export interface ActionBuild {
|
|
|
200
225
|
status: ActionBuildStatus
|
|
201
226
|
/** Why it is not running, in words. */
|
|
202
227
|
reason?: string
|
|
228
|
+
/** failed: the deployment validation's failed findings, `code: message` each, when the runtime gave them. */
|
|
229
|
+
findings?: string[]
|
|
230
|
+
/** left-off: the runtime refused the Action's own file (a failure to fix), not left off by design. */
|
|
231
|
+
refused?: boolean
|
|
232
|
+
/**
|
|
233
|
+
* Deployed as the workspace runs it, with these warnings inherited from the
|
|
234
|
+
* workspace: a value it offers that the semantic type it writes refuses.
|
|
235
|
+
*/
|
|
236
|
+
inherited?: string[]
|
|
203
237
|
}
|
|
204
238
|
|
|
205
239
|
interface Deployable {
|
|
206
240
|
definition: PublishedActionDefinition
|
|
207
241
|
contractDigest: string
|
|
208
242
|
deploymentStatus?: string
|
|
243
|
+
availability?: string
|
|
244
|
+
reason?: string | null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Why one Action's write path did not deploy: a sentence, and the validation's failed findings when it had any. */
|
|
248
|
+
interface DeployFailure {
|
|
249
|
+
reason: string
|
|
250
|
+
findings?: string[]
|
|
209
251
|
}
|
|
210
252
|
|
|
211
253
|
/** The write path for one Action that writes the Blueprint's records: built, validated, reviewed, activated. */
|
|
212
|
-
async function deployOne(call: Call, published: Deployable): Promise<string | null> {
|
|
254
|
+
async function deployOne(call: Call, published: Deployable): Promise<string | DeployFailure | null> {
|
|
213
255
|
const { definition, contractDigest } = published
|
|
214
256
|
const types = data<Array<{ id?: string; apiName?: string }>>(await call('org', 'GET', '/v1/blueprint/object-types')) ?? []
|
|
215
257
|
const subject = (Array.isArray(types) ? types : []).find((t) => t.id === definition.subject.objectTypeId)?.apiName
|
|
@@ -238,7 +280,11 @@ async function deployOne(call: Call, published: Deployable): Promise<string | nu
|
|
|
238
280
|
const bindingRevisionId = data<{ id: string }>(binding).id
|
|
239
281
|
const validation = await call('org', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/validate`, { expectedStateGeneration: 1 })
|
|
240
282
|
const report = data<{ id?: string; status?: string; findings?: unknown }>(validation)
|
|
241
|
-
if (validation.status >= 300 || report?.status !== 'valid')
|
|
283
|
+
if (validation.status >= 300 || report?.status !== 'valid') {
|
|
284
|
+
const findings = validation.status >= 300 ? [] : validationFindings(report)
|
|
285
|
+
if (findings.length) return { reason: `its write path did not validate (${findings.length} failed check${findings.length === 1 ? '' : 's'})`, findings }
|
|
286
|
+
return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
|
|
287
|
+
}
|
|
242
288
|
// A different principal from the key that built it: the service insists.
|
|
243
289
|
const reviewed = await call('session', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/review`, { validationReportId: report.id, expectedStateGeneration: 2 })
|
|
244
290
|
if (reviewed.status >= 300) return `its review was refused: ${message(reviewed)}`
|
|
@@ -288,19 +334,66 @@ export async function deployActions(call: Call, actions: LocalAction[]): Promise
|
|
|
288
334
|
continue
|
|
289
335
|
}
|
|
290
336
|
const published = data<Deployable>(read)
|
|
291
|
-
let failure:
|
|
292
|
-
|
|
337
|
+
let failure: DeployFailure | null = null
|
|
338
|
+
// Not active, or active on a write path whose validation no longer holds: built again.
|
|
339
|
+
if (published.deploymentStatus !== 'active' || published.reason === 'ACTION_BINDING_VALIDATION_STALE') {
|
|
293
340
|
try {
|
|
294
|
-
|
|
341
|
+
const why = await deployOne(call, published)
|
|
342
|
+
failure = typeof why === 'string' ? { reason: why } : why
|
|
295
343
|
} catch (err) {
|
|
296
|
-
failure = (err as Error).message
|
|
344
|
+
failure = { reason: (err as Error).message }
|
|
297
345
|
}
|
|
298
346
|
}
|
|
299
347
|
if (!failure && action.invokeCapability) {
|
|
300
348
|
const granted = await call('session', 'POST', `${ADMIN}/capability-grants`, { capability: action.invokeCapability, role: 'owner', revoke: false })
|
|
301
|
-
if (granted.status >= 300) failure = `it is deployed, but the owner role could not be allowed to run it: ${message(granted)}`
|
|
349
|
+
if (granted.status >= 300) failure = { reason: `it is deployed, but the owner role could not be allowed to run it: ${message(granted)}` }
|
|
302
350
|
}
|
|
303
|
-
out[action.apiName] = failure ? { status: 'failed', reason: failure } : { status: 'runs' }
|
|
351
|
+
out[action.apiName] = failure ? { status: 'failed', reason: failure.reason, ...(failure.findings?.length ? { findings: failure.findings } : {}) } : { status: 'runs' }
|
|
352
|
+
}
|
|
353
|
+
return out
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** One published Action's deployment on the runtime, as `?view=deployable` answers it: what `frontera action list` reads too. */
|
|
357
|
+
export interface LiveAction {
|
|
358
|
+
deploymentStatus: string
|
|
359
|
+
availability?: string
|
|
360
|
+
reason?: string | null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether a live Action runs: deployed, active, and its deployment validated against its published contract. */
|
|
364
|
+
export function liveActionRuns(live: LiveAction | undefined): boolean {
|
|
365
|
+
return !!live && live.deploymentStatus === 'active' && (live.availability === undefined || live.availability === 'available')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Every published Action's deployment on the local runtime, by apiName, from
|
|
370
|
+
* the same view `frontera action list` reads (`/v1/blueprint/actions?view=deployable`),
|
|
371
|
+
* so `local status`, `local dev` and `action list` say one thing. Null when it
|
|
372
|
+
* could not be read.
|
|
373
|
+
*/
|
|
374
|
+
export async function liveActionStates(call: Call): Promise<Map<string, LiveAction> | null> {
|
|
375
|
+
const listed = await call('org', 'GET', '/v1/blueprint/actions?view=deployable')
|
|
376
|
+
if (listed.status >= 300) return null
|
|
377
|
+
const rows = data<Array<{ definition?: { apiName?: string }; deploymentStatus?: string; availability?: string; reason?: string | null }>>(listed)
|
|
378
|
+
if (!Array.isArray(rows)) return null
|
|
379
|
+
const out = new Map<string, LiveAction>()
|
|
380
|
+
for (const r of rows) {
|
|
381
|
+
const name = r.definition?.apiName
|
|
382
|
+
if (typeof name === 'string' && typeof r.deploymentStatus === 'string') out.set(name, { deploymentStatus: r.deploymentStatus, ...(r.availability ? { availability: r.availability } : {}), reason: r.reason ?? null })
|
|
304
383
|
}
|
|
305
384
|
return out
|
|
306
385
|
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Of the Actions that should run here, the ones the runtime does not run, each
|
|
389
|
+
* with why: a file left unchanged after a failed apply still needs its Action
|
|
390
|
+
* deployed again. Empty when the runtime could not be read (the apply decides).
|
|
391
|
+
*/
|
|
392
|
+
export function actionsNotRunning(expected: string[], live: Map<string, LiveAction> | null): Array<{ apiName: string; why: string }> {
|
|
393
|
+
if (!live) return []
|
|
394
|
+
return expected.flatMap((apiName) => {
|
|
395
|
+
const l = live.get(apiName)
|
|
396
|
+
if (liveActionRuns(l)) return []
|
|
397
|
+
return [{ apiName, why: !l ? 'it is not published on the runtime' : `its deployment is ${l.deploymentStatus}${l.availability && l.availability !== 'available' && l.availability !== l.deploymentStatus ? ` (${l.availability})` : ''}${l.reason ? `, ${l.reason}` : ''}` }]
|
|
398
|
+
})
|
|
399
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse as parseYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { unchangedSincePull } from './local-check-inherited'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Governed Actions an Agent's instructions tell it to submit that its
|
|
10
|
+
* Blueprint grants never offer it.
|
|
11
|
+
*
|
|
12
|
+
* An Agent is offered an Action only on an object type its grant lets it act
|
|
13
|
+
* on (`"act"` in `agents/<slug>/bindings.json`); with `read` alone it gets the
|
|
14
|
+
* query tools and no Action tool, here and on the platform alike. Nothing in
|
|
15
|
+
* the apply refuses an Agent whose instructions say "submit
|
|
16
|
+
* `draftBudgetScenario`" while its grant on BudgetScenario is `read`: the
|
|
17
|
+
* Agent simply never has the tool, and a conversation that needs it fails.
|
|
18
|
+
* This says so before a conversation does.
|
|
19
|
+
*
|
|
20
|
+
* Kept to few false alarms: only an Action's exact apiName counts (a word
|
|
21
|
+
* boundary on both sides, case kept), only Actions the project's Blueprint
|
|
22
|
+
* defines, and a sentence that names it with a negation ("never by submitting
|
|
23
|
+
* `raiseServiceRequest` yourself", "`publishExecutiveBrief` is not yours") or
|
|
24
|
+
* with one of the Agent's own Functions or workflows (which submit it in the
|
|
25
|
+
* Agent's place) does not count. An Agent whose grants the pull couldn't read
|
|
26
|
+
* (`blueprintUnread`), or whose grant on the type names no permissions (it
|
|
27
|
+
* keeps what the runtime already holds), is not judged.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface AgentActionGap {
|
|
31
|
+
/** `leo`, from `agents/leo/`. */
|
|
32
|
+
agent: string
|
|
33
|
+
/** The object type the Actions act on (`BudgetScenario`). */
|
|
34
|
+
objectType: string
|
|
35
|
+
/** The Actions on it the instructions name, in the order they first appear. */
|
|
36
|
+
actions: string[]
|
|
37
|
+
/** What the Agent holds on the type: its permissions, or null without a grant. */
|
|
38
|
+
permissions: string[] | null
|
|
39
|
+
/** Where the instructions name them: `agents/leo/agent.json`, `agents/nova/operating.json`. */
|
|
40
|
+
files: string[]
|
|
41
|
+
/** The Agent's files are as the last pull wrote them: the workspace has the same gap. */
|
|
42
|
+
inherited: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Doc = Record<string, unknown>
|
|
46
|
+
|
|
47
|
+
function entries(dir: string): string[] {
|
|
48
|
+
try {
|
|
49
|
+
return readdirSync(dir).filter((name) => !name.startsWith('.')).sort()
|
|
50
|
+
} catch {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readDoc(path: string): unknown {
|
|
56
|
+
try {
|
|
57
|
+
const text = readFileSync(path, 'utf8')
|
|
58
|
+
return path.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isDoc = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
65
|
+
|
|
66
|
+
/** Each governed Action the project's Blueprint defines, by apiName, with the object type it acts on. */
|
|
67
|
+
export function projectActionSubjects(root: string): Map<string, string> {
|
|
68
|
+
const out = new Map<string, string>()
|
|
69
|
+
const dir = join(root, 'blueprint', 'actions')
|
|
70
|
+
for (const file of entries(dir)) {
|
|
71
|
+
if (!/\.(ya?ml|json)$/.test(file)) continue
|
|
72
|
+
const doc = readDoc(join(dir, file))
|
|
73
|
+
if (!isDoc(doc)) continue
|
|
74
|
+
const apiName = typeof doc.apiName === 'string' && doc.apiName ? doc.apiName : file.replace(/\.(ya?ml|json)$/, '')
|
|
75
|
+
const subject = isDoc(doc.subject) && typeof doc.subject.objectType === 'string' ? doc.subject.objectType.replace(/^objectType\//, '') : null
|
|
76
|
+
if (subject && /^[A-Za-z_][\w]*$/.test(apiName)) out.set(apiName, subject)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every string under a value: a procedure's description, instructions, guardrails, field descriptions… */
|
|
82
|
+
function strings(v: unknown, out: string[] = []): string[] {
|
|
83
|
+
if (typeof v === 'string') out.push(v)
|
|
84
|
+
else if (Array.isArray(v)) for (const x of v) strings(x, out)
|
|
85
|
+
else if (isDoc(v)) for (const x of Object.values(v)) strings(x, out)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Agent's instructions (its enabled prompts) and its procedures' text, by the file each is in. */
|
|
90
|
+
function instructionTexts(root: string, slug: string): Array<{ file: string; text: string }> {
|
|
91
|
+
const out: Array<{ file: string; text: string }> = []
|
|
92
|
+
const agentFile = `agents/${slug}/agent.json`
|
|
93
|
+
const agent = readDoc(join(root, agentFile))
|
|
94
|
+
if (isDoc(agent) && Array.isArray(agent.prompts)) {
|
|
95
|
+
const text = agent.prompts
|
|
96
|
+
.filter((p): p is Doc => isDoc(p) && p.enabled !== false && typeof p.content === 'string')
|
|
97
|
+
.map((p) => p.content as string)
|
|
98
|
+
.join('\n\n')
|
|
99
|
+
if (text) out.push({ file: agentFile, text })
|
|
100
|
+
}
|
|
101
|
+
const operatingFile = `agents/${slug}/operating.json`
|
|
102
|
+
const operating = readDoc(join(root, operatingFile))
|
|
103
|
+
if (isDoc(operating) && Array.isArray(operating.procedures)) {
|
|
104
|
+
const text = strings(operating.procedures).join('\n\n')
|
|
105
|
+
if (text) out.push({ file: operatingFile, text })
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The sentences of a text: split at line breaks and at a full stop, question or exclamation mark before a space. */
|
|
111
|
+
function sentences(text: string): string[] {
|
|
112
|
+
return text.split(/\n+|(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A sentence that says not to submit it, or that it isn't the Agent's to submit. */
|
|
116
|
+
const NEGATION = /\b(?:never|not|nor|cannot|without)\b|n['’]t\b/i
|
|
117
|
+
|
|
118
|
+
const word = (name: string) => new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`)
|
|
119
|
+
|
|
120
|
+
/** The Functions and workflows the Agent may start, by slug: a sentence naming one is about that tool. */
|
|
121
|
+
function boundTools(bindings: Doc): string[] {
|
|
122
|
+
const refs: string[] = []
|
|
123
|
+
for (const key of ['functions', 'workflows'] as const) {
|
|
124
|
+
const rows = Array.isArray(bindings[key]) ? (bindings[key] as unknown[]) : []
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const ref = isDoc(row) ? (row.function ?? row.workflow) : null
|
|
127
|
+
if (typeof ref === 'string' && ref.includes('/')) refs.push(ref.slice(ref.indexOf('/') + 1))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return refs.filter(Boolean)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The Actions a text tells the Agent to use, by apiName: named in a sentence that neither negates it nor hands it to a bound tool. */
|
|
134
|
+
export function namedActions(text: string, actions: Iterable<string>, tools: readonly string[] = []): string[] {
|
|
135
|
+
const names = [...actions]
|
|
136
|
+
const found: Array<{ name: string; at: number }> = []
|
|
137
|
+
const toolWords = tools.map(word)
|
|
138
|
+
for (const sentence of sentences(text)) {
|
|
139
|
+
if (NEGATION.test(sentence) || toolWords.some((w) => w.test(sentence))) continue
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const m = word(name).exec(sentence)
|
|
142
|
+
if (m && !found.some((f) => f.name === name)) found.push({ name, at: text.indexOf(sentence) + m.index })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return found.sort((a, b) => a.at - b.at).map((f) => f.name)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What a bindings.json grants on each object type, by apiName; null when it can't be told (grants the pull couldn't read). */
|
|
149
|
+
function grantsOf(bindings: Doc): Map<string, string[] | null> | null {
|
|
150
|
+
if (bindings.blueprintUnread === true && !Array.isArray(bindings.blueprint)) return null
|
|
151
|
+
const out = new Map<string, string[] | null>()
|
|
152
|
+
for (const row of Array.isArray(bindings.blueprint) ? bindings.blueprint : []) {
|
|
153
|
+
if (!isDoc(row) || typeof row.objectType !== 'string') continue
|
|
154
|
+
const type = row.objectType.replace(/^objectType\//, '')
|
|
155
|
+
out.set(type, Array.isArray(row.permissions) ? row.permissions.filter((p): p is string => typeof p === 'string') : null)
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every Agent whose instructions or procedures name Actions it can't submit, one entry per Agent and object type. */
|
|
161
|
+
export function agentActionGaps(root: string): AgentActionGap[] {
|
|
162
|
+
const subjects = projectActionSubjects(root)
|
|
163
|
+
if (!subjects.size) return []
|
|
164
|
+
const out: AgentActionGap[] = []
|
|
165
|
+
let unchanged: ReturnType<typeof unchangedSincePull> | null = null
|
|
166
|
+
for (const slug of entries(join(root, 'agents'))) {
|
|
167
|
+
if (!existsSync(join(root, 'agents', slug, 'agent.json'))) continue
|
|
168
|
+
const bindingsFile = `agents/${slug}/bindings.json`
|
|
169
|
+
const bindings = readDoc(join(root, bindingsFile))
|
|
170
|
+
const doc: Doc = isDoc(bindings) ? bindings : {}
|
|
171
|
+
const grants = grantsOf(doc)
|
|
172
|
+
if (!grants) continue
|
|
173
|
+
const tools = boundTools(doc)
|
|
174
|
+
const byType = new Map<string, AgentActionGap>()
|
|
175
|
+
for (const { file, text } of instructionTexts(root, slug)) {
|
|
176
|
+
for (const action of namedActions(text, subjects.keys(), tools)) {
|
|
177
|
+
const type = subjects.get(action)!
|
|
178
|
+
const held = grants.has(type) ? grants.get(type)! : null
|
|
179
|
+
// A grant that names no permissions keeps what the runtime holds: it may act, so it isn't judged.
|
|
180
|
+
if (grants.has(type) && held === null) continue
|
|
181
|
+
if (held?.includes('act')) continue
|
|
182
|
+
const gap = byType.get(type) ?? { agent: slug, objectType: type, actions: [], permissions: held, files: [], inherited: false }
|
|
183
|
+
if (!gap.actions.includes(action)) gap.actions.push(action)
|
|
184
|
+
if (!gap.files.includes(file)) gap.files.push(file)
|
|
185
|
+
byType.set(type, gap)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!byType.size) continue
|
|
189
|
+
unchanged ??= unchangedSincePull(root)
|
|
190
|
+
for (const gap of byType.values()) {
|
|
191
|
+
gap.inherited = [...gap.files, bindingsFile].every((f) => unchanged!.files.has(f))
|
|
192
|
+
out.push(gap)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const list = (names: string[]) => (names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
|
|
199
|
+
|
|
200
|
+
/** One gap in a line, with its fix: what `local dev`, `local status` and the MCP status say. */
|
|
201
|
+
export function agentActionGapLine(g: AgentActionGap): string {
|
|
202
|
+
const one = g.actions.length === 1
|
|
203
|
+
const named = `${one ? 'the governed Action' : 'the governed Actions'} ${list(g.actions.map((a) => `\`${a}\``))}`
|
|
204
|
+
const held = g.permissions === null ? `it has no grant on ${g.objectType}` : `its grant on ${g.objectType} is ${list(g.permissions.length ? g.permissions : ['empty'])} without act`
|
|
205
|
+
const where = g.files.map((f) => (f.endsWith('operating.json') ? 'procedures (operating.json)' : 'instructions (agent.json)')).join(' and ')
|
|
206
|
+
const fix = g.permissions === null
|
|
207
|
+
? `add { "objectType": "objectType/${g.objectType}", "permissions": ["read", "act"] } to agents/${g.agent}/bindings.json`
|
|
208
|
+
: `add "act" to objectType/${g.objectType}'s permissions in agents/${g.agent}/bindings.json`
|
|
209
|
+
return `agent/${g.agent}: its ${where} tell it to submit ${named}, but ${held}, so it is offered no Action on ${g.objectType}, here and on the platform. Fix: ${fix}, or take ${one ? 'it' : 'them'} out of its instructions${g.inherited ? ' (inherited: these files are as the pull wrote them, so the workspace’s Agent has the same gap)' : ''}`
|
|
210
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parseRef } from './local-sync-refs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What an Agent's files say that `local dev` would otherwise apply without a
|
|
8
|
+
* word and the Agent would only trip over in a chat: a key in agent.json
|
|
9
|
+
* whose content belongs in another file, and a procedure's tool list naming
|
|
10
|
+
* a tool in a form the Agent cannot use, or one it is not bound to.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
14
|
+
|
|
15
|
+
/** The tool groups a procedure may keep (`TOOL_GROUPS` in the service's operating config). */
|
|
16
|
+
export const PROCEDURE_TOOL_GROUPS = ['plugins', 'blueprint', 'blueprint-actions', 'web'] as const
|
|
17
|
+
|
|
18
|
+
/** The forms a procedure's `tools.ids` take, in words, for a schema description and an error. */
|
|
19
|
+
export const PROCEDURE_TOOL_FORMS =
|
|
20
|
+
'`function:<slug>` (a Function bound to the Agent in agents/<slug>/bindings.json), `workflow:<slug>` (a workflow bound there), or `tool:plugins`, `tool:blueprint`, `tool:blueprint-actions`, `tool:web` (the Agent’s Plugins; its Blueprint query tools only; its governed Action tools, which list, submit and read Action requests; web search)'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Keys in agent.json whose content does not do there what was meant: each a
|
|
24
|
+
* `warning:` line naming the file it belongs in. The Agent is still applied.
|
|
25
|
+
*/
|
|
26
|
+
export function agentFileWarnings(doc: unknown, slug: string): string[] {
|
|
27
|
+
if (!isRecord(doc)) return []
|
|
28
|
+
const out: string[] = []
|
|
29
|
+
const bindings = `agents/${slug}/bindings.json`
|
|
30
|
+
const file = `agents/${slug}/agent.json`
|
|
31
|
+
const rows = (key: string) => (Array.isArray(doc[key]) ? (doc[key] as unknown[]) : [])
|
|
32
|
+
// `objectBindings` binds workspace object definitions; an object type there grants nothing.
|
|
33
|
+
rows('objectBindings').forEach((row, i) => {
|
|
34
|
+
if (!isRecord(row)) return
|
|
35
|
+
const id = typeof row.objectDefinitionId === 'string' ? parseRef(row.objectDefinitionId) : null
|
|
36
|
+
if (id?.kind === 'object') return
|
|
37
|
+
const named = [row.objectType, row.objectTypeApiName, row.apiName, row.objectDefinitionId].find((v) => typeof v === 'string') as string | undefined
|
|
38
|
+
const type = named ? (parseRef(named)?.name ?? named) : '<ApiName>'
|
|
39
|
+
out.push(
|
|
40
|
+
` warning: ${file} objectBindings.${i} is not applied: objectBindings binds an object definition (\`objectDefinitionId: object/<slug>\` with its \`definition\`). For Blueprint records the Agent may query, list the object type in ${bindings}: "blueprint": [{ "objectType": "objectType/${type}", "permissions": ["read"] }]`,
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
if (rows('functions').length) {
|
|
44
|
+
out.push(` warning: ${file} functions is not applied: nothing runs those rows any more. The Functions the Agent may call are its bindings: ${bindings} "functions": [{ "function": "function/<slug>" }]`)
|
|
45
|
+
}
|
|
46
|
+
rows('knowledgeAttachments').forEach((row, i) => {
|
|
47
|
+
if (!isRecord(row)) return
|
|
48
|
+
const id = row.knowledgeConfigId
|
|
49
|
+
if (typeof id !== 'string' || (!parseRef(id) && !/^[0-9a-f-]{36}$/i.test(id))) {
|
|
50
|
+
out.push(` warning: ${file} knowledgeAttachments.${i} names no knowledge base: write { "knowledgeConfigId": "knowledge/<name>" } (knowledge/<name>/knowledge.json)`)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The keys agent.json may not carry at all: refused before the runtime says "unrecognized key". */
|
|
57
|
+
export function agentFileErrors(doc: unknown, slug: string): string[] {
|
|
58
|
+
if (!isRecord(doc)) return []
|
|
59
|
+
const bindings = `agents/${slug}/bindings.json`
|
|
60
|
+
return ['workflows', 'workflowBindings', 'functionBindings', 'bindings', 'blueprint', 'grants', 'blueprintGrants', 'objectTypes']
|
|
61
|
+
.filter((key) => key in doc)
|
|
62
|
+
.map((key) => `agents/${slug}/agent.json: \`${key}\` is not an agent.json key; what an Agent may start and read goes in ${bindings} (\`frontera local schema bindings\`)`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What `agents/<slug>/bindings.json` binds, by slug: the Functions and workflows. */
|
|
66
|
+
export function boundTools(root: string, slug: string): { functions: Set<string>; workflows: Set<string>; blueprint: boolean; objectTypes?: Set<string>; acting?: Set<string> } | null {
|
|
67
|
+
const path = join(root, 'agents', slug, 'bindings.json')
|
|
68
|
+
if (!existsSync(path)) return { functions: new Set(), workflows: new Set(), blueprint: false, objectTypes: new Set(), acting: new Set() }
|
|
69
|
+
try {
|
|
70
|
+
const doc = JSON.parse(readFileSync(path, 'utf8')) as { functions?: Array<{ function?: unknown }>; workflows?: Array<{ workflow?: unknown }>; blueprint?: unknown[] }
|
|
71
|
+
const names = (rows: Array<Record<string, unknown>> | undefined, key: string, kind: string) =>
|
|
72
|
+
new Set((rows ?? []).flatMap((r) => {
|
|
73
|
+
const ref = typeof r?.[key] === 'string' ? parseRef(r[key] as string) : null
|
|
74
|
+
return ref?.kind === kind ? [ref.name] : []
|
|
75
|
+
}))
|
|
76
|
+
return {
|
|
77
|
+
functions: names(doc.functions as Array<Record<string, unknown>>, 'function', 'function'),
|
|
78
|
+
workflows: names(doc.workflows as Array<Record<string, unknown>>, 'workflow', 'workflow'),
|
|
79
|
+
blueprint: Array.isArray(doc.blueprint) && doc.blueprint.length > 0,
|
|
80
|
+
// The object types granted, by apiName (`objectType/WorkOrder`).
|
|
81
|
+
objectTypes: names(doc.blueprint as Array<Record<string, unknown>>, 'objectType', 'objectType'),
|
|
82
|
+
// Those it may submit governed Actions on (`act` in the grant's permissions); unknown when an entry
|
|
83
|
+
// names none (a pull that read names only), since that entry keeps whatever the grant holds.
|
|
84
|
+
acting: (doc.blueprint as Array<Record<string, unknown>> | undefined)?.some((g) => !Array.isArray(g?.permissions)) ? undefined : names((doc.blueprint as Array<Record<string, unknown>> | undefined)?.filter((g) => Array.isArray(g?.permissions) && (g.permissions as unknown[]).includes('act')), 'objectType', 'objectType'),
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A procedure's `tools.ids` checked before the behavior settings are staged:
|
|
93
|
+
* an id in a form the Agent cannot use fails the apply (the runtime accepts
|
|
94
|
+
* it and the chat then refuses the procedure with "keeps a tool that does not
|
|
95
|
+
* exist"); one that names a Function or workflow the Agent is not bound to is
|
|
96
|
+
* a warning naming the bindings file. `bound` is null when bindings.json could
|
|
97
|
+
* not be read, and then only the forms are checked.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The warning for an operating.json without `execution.toolScopes: 2`, or
|
|
101
|
+
* null when it has it. Without the marker the platform reads the file as
|
|
102
|
+
* before Blueprint Actions were their own choice: `tool:blueprint` keeps the
|
|
103
|
+
* Action tools, and a service Agent has them outside procedures.
|
|
104
|
+
*/
|
|
105
|
+
export function toolScopesProblem(doc: unknown, file: string): string | null {
|
|
106
|
+
if (!isRecord(doc)) return null
|
|
107
|
+
const execution = isRecord(doc.execution) ? doc.execution : {}
|
|
108
|
+
if (execution.toolScopes === 2) return null
|
|
109
|
+
const said = execution.toolScopes === undefined ? 'has no `execution.toolScopes`' : `has \`execution.toolScopes: ${JSON.stringify(execution.toolScopes)}\``
|
|
110
|
+
return ` warning: ${file} ${said}, so it is read as before Blueprint Actions were their own choice: \`tool:blueprint\` keeps the Action tools, and a service Agent has them outside procedures; add "toolScopes": 2 to "execution" to use the new meaning (\`tool:blueprint\` the query tools only, \`tool:blueprint-actions\` the Action tools, \`execution.blueprintActions\` for outside procedures)`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function procedureToolProblems(doc: unknown, slug: string, bound: ReturnType<typeof boundTools>): { errors: string[]; warnings: string[] } {
|
|
114
|
+
const errors: string[] = []
|
|
115
|
+
const warnings: string[] = []
|
|
116
|
+
const procedures = isRecord(doc) && Array.isArray(doc.procedures) ? (doc.procedures as unknown[]) : []
|
|
117
|
+
const file = `agents/${slug}/operating.json`
|
|
118
|
+
// Once per Agent: a config without the marker keeps the meaning tool ids had before.
|
|
119
|
+
const scopes = toolScopesProblem(doc, file)
|
|
120
|
+
if (scopes) warnings.push(scopes)
|
|
121
|
+
// A data scope narrows reads of an object type the Agent is granted: one it is not granted is read by nothing.
|
|
122
|
+
const dataScopes = isRecord(doc) && Array.isArray(doc.dataScopes) ? (doc.dataScopes as unknown[]) : []
|
|
123
|
+
dataScopes.forEach((d, i) => {
|
|
124
|
+
if (!isRecord(d) || typeof d.objectType !== 'string' || !bound?.objectTypes) return
|
|
125
|
+
const type = parseRef(d.objectType)?.name ?? d.objectType
|
|
126
|
+
if (!bound.objectTypes.has(type)) warnings.push(` warning: ${file}: dataScopes.${i} scopes ${type}, which agents/${slug}/bindings.json does not grant, so the Agent reads none of it and the scope does nothing: add { "objectType": "objectType/${type}", "permissions": ["read"] } to its "blueprint"`)
|
|
127
|
+
})
|
|
128
|
+
// The Action tools asked for (outside procedures, or kept by one) while no grant lets the Agent act: it is offered none.
|
|
129
|
+
const execution = isRecord(doc) && isRecord(doc.execution) ? doc.execution : {}
|
|
130
|
+
const keepsActions = procedures.some((p) => isRecord(p) && isRecord(p.tools) && p.tools.inherit === false && Array.isArray(p.tools.ids) && p.tools.ids.includes('tool:blueprint-actions'))
|
|
131
|
+
if ((execution.blueprintActions === true || keepsActions) && bound?.blueprint && bound.acting && bound.acting.size === 0) {
|
|
132
|
+
const types = [...(bound.objectTypes ?? [])].sort()
|
|
133
|
+
const on = types.length ? (types.length > 3 ? `${types.slice(0, 3).join(', ')} or another type it reads` : types.join(' or ')) : '<Type>'
|
|
134
|
+
warnings.push(` warning: ${slug} can't submit Actions: give its grant on ${on} \`act\` in agents/${slug}/bindings.json ("permissions": ["read", "act"]). ${file} asks for the Action tools (${execution.blueprintActions === true ? '`execution.blueprintActions`' : '`tool:blueprint-actions`'}), and without act on the type an Action is on, it is offered none`)
|
|
135
|
+
}
|
|
136
|
+
procedures.forEach((p, i) => {
|
|
137
|
+
if (!isRecord(p)) return
|
|
138
|
+
const name = typeof p.name === 'string' ? p.name : `procedure ${i + 1}`
|
|
139
|
+
// An action that starts a Function or workflow needs the same binding as a kept tool.
|
|
140
|
+
if (bound && Array.isArray(p.actions)) {
|
|
141
|
+
;(p.actions as unknown[]).forEach((a, k) => {
|
|
142
|
+
if (!isRecord(a)) return
|
|
143
|
+
for (const [key, kind] of [['functionId', 'function'], ['workflowId', 'workflow']] as const) {
|
|
144
|
+
const ref = typeof a[key] === 'string' ? parseRef(a[key] as string) : null
|
|
145
|
+
if (ref?.kind !== kind) continue
|
|
146
|
+
if (!(kind === 'function' ? bound.functions : bound.workflows).has(ref.name)) {
|
|
147
|
+
warnings.push(` warning: ${file}: procedures.${i}.actions.${k}.${key}: "${name}" starts ${kind} ${ref.name}, which agents/${slug}/bindings.json does not bind, so the chat refuses the procedure: add { "${kind}": "${kind}/${ref.name}" } to its "${kind}s"`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
// Only a procedure that keeps its own tools (`inherit: false`) uses the list.
|
|
153
|
+
if (!isRecord(p.tools) || p.tools.inherit !== false || !Array.isArray(p.tools.ids)) return
|
|
154
|
+
;(p.tools.ids as unknown[]).forEach((id, j) => {
|
|
155
|
+
const at = `${file}: procedures.${i}.tools.ids.${j}`
|
|
156
|
+
if (typeof id !== 'string') {
|
|
157
|
+
errors.push(`${at}: must be text, one of ${PROCEDURE_TOOL_FORMS}`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const m = /^(function|workflow|tool):(.+)$/.exec(id)
|
|
161
|
+
if (!m) {
|
|
162
|
+
// A reference written the file way (`function/x`) is the same tool in the procedure's form.
|
|
163
|
+
const ref = parseRef(id)
|
|
164
|
+
const hint = ref && (ref.kind === 'function' || ref.kind === 'workflow') ? `; write \`${ref.kind}:${ref.name}\`` : ''
|
|
165
|
+
errors.push(`${at}: "${id}" is not a tool "${name}" can keep${hint}. A tool is ${PROCEDURE_TOOL_FORMS}`)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const [, kind, target] = m as unknown as [string, 'function' | 'workflow' | 'tool', string]
|
|
169
|
+
if (kind === 'tool') {
|
|
170
|
+
if (!(PROCEDURE_TOOL_GROUPS as readonly string[]).includes(target)) errors.push(`${at}: "${id}" is not a tool group; the groups are ${PROCEDURE_TOOL_GROUPS.map((g) => `tool:${g}`).join(', ')}`)
|
|
171
|
+
else if ((target === 'blueprint' || target === 'blueprint-actions') && bound && !bound.blueprint) warnings.push(` warning: ${at}: "${name}" keeps the ${target === 'blueprint' ? 'Blueprint query tools' : 'governed Action tools'}, but agents/${slug}/bindings.json grants no object type ("blueprint": [{ "objectType": "objectType/<ApiName>" }]), so the Agent has none`)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
// A runtime id passes: `local dev` writes names, and a pulled file may still hold one.
|
|
175
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(target) || !bound) return
|
|
176
|
+
const set = kind === 'function' ? bound.functions : bound.workflows
|
|
177
|
+
if (!set.has(target)) {
|
|
178
|
+
warnings.push(
|
|
179
|
+
` warning: ${at}: "${name}" keeps ${kind} ${target}, which agents/${slug}/bindings.json does not bind, so the chat refuses the procedure: add { "${kind}": "${kind}/${target}" } to its "${kind}s"`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
return { errors, warnings }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What `requiresApproval: false` on a binding does, said where it is written
|
|
189
|
+
* (agents/<slug>/bindings.json), so it is read as "never asks": the binding
|
|
190
|
+
* is Unattended, and the Agent runs the call without a card in every approval
|
|
191
|
+
* mode, attended or not, with or without an operating.json. Only a dataset run
|
|
192
|
+
* on the published Agent still asks before one that changes something. A
|
|
193
|
+
* Function that asks the person itself still shows its own question. Each
|
|
194
|
+
* line starts `<file>: requiresApproval false`, which `isApprovalNote` tells
|
|
195
|
+
* apart from an adaptation to this runtime: it holds everywhere.
|
|
196
|
+
*/
|
|
197
|
+
export function approvalBindingNotes(root: string, slug: string, doc: unknown): string[] {
|
|
198
|
+
if (!isRecord(doc)) return []
|
|
199
|
+
const file = `agents/${slug}/bindings.json`
|
|
200
|
+
const rows = (key: string) => (Array.isArray(doc[key]) ? (doc[key] as unknown[]).filter(isRecord) : [])
|
|
201
|
+
const ALWAYS = 'Runs without a card in every approval mode, whether or not someone is in the chat; only a dataset run on the published Agent still asks before it changes something. Keep it for an action that is safe to run unasked.'
|
|
202
|
+
const out: string[] = []
|
|
203
|
+
for (const row of rows('functions')) {
|
|
204
|
+
if (row.requiresApproval !== false || typeof row.function !== 'string') continue
|
|
205
|
+
const name = parseRef(row.function)?.name ?? row.function
|
|
206
|
+
const extra = functionAsksPerson(root, name) ? ['The Function still asks the person itself (its confirmation card).'] : []
|
|
207
|
+
out.push(` warning: ${file}: requiresApproval false on function/${name}: ${[ALWAYS, ...extra].join(' ')}`)
|
|
208
|
+
}
|
|
209
|
+
for (const row of rows('workflows')) {
|
|
210
|
+
if (row.requiresApproval !== false || typeof row.workflow !== 'string') continue
|
|
211
|
+
const name = parseRef(row.workflow)?.name ?? row.workflow
|
|
212
|
+
out.push(` warning: ${file}: requiresApproval false on workflow/${name}: ${ALWAYS} Each Workflow binding asks on its own.`)
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A note `approvalBindingNotes` made: said, but not an adaptation to this runtime. */
|
|
218
|
+
export function isApprovalNote(line: string): boolean {
|
|
219
|
+
return /^\s*warning: agents\/[^/]+\/bindings\.json: requiresApproval false on /.test(line)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether a Function's source asks the person itself (`ctx.person.confirm`): a source search, as its file holds it. */
|
|
223
|
+
function functionAsksPerson(root: string, name: string): boolean {
|
|
224
|
+
for (const path of [join(root, 'functions', `${name}.ts`), join(root, 'functions', name, 'index.ts'), join(root, 'functions', name, 'src', 'index.ts')]) {
|
|
225
|
+
try {
|
|
226
|
+
if (/\bperson\s*\.\s*confirm\s*\(/.test(readFileSync(path, 'utf8'))) return true
|
|
227
|
+
} catch {
|
|
228
|
+
// Not this path.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false
|
|
232
|
+
}
|