@frontera-sdk/cli 1.52.47 → 1.53.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.47",
3
+ "version": "1.53.0",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.52.47",
44
- "@frontera-sdk/functions": "1.52.47",
43
+ "@frontera-sdk/core": "1.53.0",
44
+ "@frontera-sdk/functions": "1.53.0",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.52.47",
52
+ "@frontera-sdk/forge-contracts": "1.53.0",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -4,7 +4,7 @@ import type { LocalCredentials } from './local-state'
4
4
 
5
5
  /**
6
6
  * An Agent's behavior settings (`agents/<slug>/operating.json`: harness,
7
- * procedures, variables, focus, reply checks) staged onto its draft.
7
+ * procedures, variables, focus, reply checks, handover reasons) staged onto its draft.
8
8
  *
9
9
  * There is no CLI command for these: the route takes a person's session, not
10
10
  * an API key. The local runtime has the owner's session, as Studio does, so
@@ -31,6 +31,26 @@ interface Issue {
31
31
  message?: string
32
32
  }
33
33
 
34
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
35
+
36
+ /**
37
+ * The keys of the handover reasons an Agent has while its operating.json sets
38
+ * none, as the service's `DEFAULT_HANDOVER_REASONS` (`handover-reasons.ts`)
39
+ * holds them: what a handoff action's `handoverReason` may name then.
40
+ */
41
+ export const DEFAULT_HANDOVER_REASON_KEYS = ['asked_for_person', 'cannot_answer', 'needs_action', 'other'] as const
42
+
43
+ /** The reason keys a saved configuration offers: its own, or the defaults. */
44
+ function reasonKeys(config: unknown): string[] {
45
+ const own = isRecord(config) && Array.isArray(config.handoverReasons) ? (config.handoverReasons as unknown[]).flatMap((r) => (isRecord(r) && typeof r.key === 'string' ? [r.key] : [])) : []
46
+ return own.length ? own : [...DEFAULT_HANDOVER_REASON_KEYS]
47
+ }
48
+
49
+ /** What to do about a refused `handoverReasons` list, said once after its problems. */
50
+ function handoverReasonsFix(slug: string): string {
51
+ return ` Fix: in agents/${slug}/operating.json give "handoverReasons" 1 to 15 reasons, each { "key", "name", "whenToUse" } with a unique lower_snake_case key (at most 40 characters), a name of 1 to 60 characters and whenToUse of 1 to 300, one of them with the key "other"; or leave "handoverReasons" out for the default reasons. \`frontera local schema operating\` gives the format`
52
+ }
53
+
34
54
  /**
35
55
  * The issues a saved draft still has, one `warning:` line each, named by the
36
56
  * project file and the setting's path
@@ -39,31 +59,104 @@ interface Issue {
39
59
  * with the warnings and refuses publishing, so it is said too, as that.
40
60
  */
41
61
  export function operatingIssueLines(slug: string, body: unknown): string[] {
42
- const data = ((body as { data?: unknown } | null)?.data ?? body) as { issues?: unknown } | null
62
+ const data = ((body as { data?: unknown } | null)?.data ?? body) as { issues?: unknown; draft?: unknown } | null
43
63
  const issues = Array.isArray(data?.issues) ? (data.issues as Issue[]) : []
44
64
  return issues.map((issue) => {
45
65
  const path = typeof issue.path === 'string' ? issue.path : ''
46
66
  // The Agent's instructions are its agent.json prompts, not its behavior settings.
47
67
  const file = path === 'instructions' || path.startsWith('instructions.') ? `agents/${slug}/agent.json` : `agents/${slug}/operating.json`
48
68
  const blocks = issue.severity === 'error' ? ' (publishing is refused until this is fixed)' : ''
49
- return ` warning: ${file}${path ? ` ${path}` : ''}: ${issue.message ?? issue.code ?? 'see the Agent’s settings'}${blocks}`
69
+ // A handoff action naming a reason the Agent doesn't have: say which keys it has.
70
+ const fix = issue.code === 'unknown_handover_reason'
71
+ ? `; set "handoverReason" to one of ${reasonKeys(data?.draft).map((k) => `"${k}"`).join(', ')}, or add the reason to "handoverReasons"`
72
+ : ''
73
+ return ` warning: ${file}${path ? ` ${path}` : ''}: ${issue.message ?? issue.code ?? 'see the Agent’s settings'}${fix}${blocks}`
50
74
  })
51
75
  }
52
76
 
53
- function describeError(status: number, body: unknown): string {
77
+ function describeError(status: number, body: unknown, slug?: string): string {
54
78
  const b = (body ?? {}) as { message?: string; code?: string; details?: unknown; issues?: unknown }
55
79
  const lines = [`error [${b.code ?? status}]: ${b.message ?? 'the behavior settings were refused'}`]
56
80
  const issues = (b.details as { issues?: unknown } | undefined)?.issues ?? b.issues ?? b.details
57
81
  if (Array.isArray(issues)) {
58
- for (const issue of issues.slice(0, 6)) {
59
- const i = issue as { path?: unknown; message?: string }
60
- const path = Array.isArray(i.path) ? i.path.join('.') : typeof i.path === 'string' ? i.path : ''
61
- lines.push(` ${path ? `${path}: ` : ''}${i.message ?? JSON.stringify(issue)}`)
62
- }
82
+ const paths = issues.map((issue) => {
83
+ const i = issue as { path?: unknown }
84
+ return Array.isArray(i.path) ? i.path.join('.') : typeof i.path === 'string' ? i.path : ''
85
+ })
86
+ issues.slice(0, 6).forEach((issue, n) => {
87
+ const path = paths[n]!
88
+ lines.push(` ${path ? `${path}: ` : ''}${(issue as { message?: string }).message ?? JSON.stringify(issue)}`)
89
+ })
90
+ // The rules of the handover reasons, said once: the service's words name one problem each, not the whole rule.
91
+ if (slug && paths.some((p) => p === 'handoverReasons' || p.startsWith('handoverReasons.'))) lines.push(handoverReasonsFix(slug))
63
92
  }
64
93
  return lines.join('\n')
65
94
  }
66
95
 
96
+ /**
97
+ * The settings the file has that the saved draft does not: a runtime on a
98
+ * release older than the file's format keeps only the keys it knows and
99
+ * drops the rest without an error. Each is a warning that says it is left
100
+ * out here and how to run the release that has it. The keys checked are the
101
+ * top-level settings and each action's `handoverReason`; nothing is said when
102
+ * the answer carries no draft to compare.
103
+ */
104
+ export function droppedSettingLines(slug: string, sent: unknown, saved: unknown): string[] {
105
+ if (!isRecord(sent) || !isRecord(saved)) return []
106
+ const file = `agents/${slug}/operating.json`
107
+ const next = 'to use it here, run the runtime on the current release: `frontera local stop`, then `frontera local start`'
108
+ // Settings the service retired and drops on purpose (`dropRetiredKeys`).
109
+ const retired = new Set(['audience', 'conversation'])
110
+ const out = Object.keys(sent)
111
+ // `$schema` and other editor keys are not settings.
112
+ .filter((key) => !key.startsWith('$') && !retired.has(key) && !(key in saved))
113
+ .map((key) => key === 'handoverReasons'
114
+ ? ` warning: ${file} handoverReasons: this runtime's release has no handover reasons, so it left them out: here the Agent hands over without them; they still apply where the Agent runs on a release that has them. To use them here, run the runtime on the current release: \`frontera local stop\`, then \`frontera local start\``
115
+ : ` warning: ${file} ${key}: this runtime's format has no \`${key}\`, so it is left out here: check the name against \`frontera local schema operating\`, or, for a setting of a newer release, ${next}`)
116
+ const sentProcedures = Array.isArray(sent.procedures) ? sent.procedures : []
117
+ const savedProcedures = Array.isArray(saved.procedures) ? saved.procedures : []
118
+ sentProcedures.forEach((p, i) => {
119
+ const kept = savedProcedures[i]
120
+ if (!isRecord(p) || !isRecord(kept)) return
121
+ if (Array.isArray(p.actions) && Array.isArray(kept.actions)) {
122
+ p.actions.forEach((a, j) => {
123
+ const k = (kept.actions as unknown[])[j]
124
+ if (isRecord(a) && typeof a.handoverReason === 'string' && isRecord(k) && !('handoverReason' in k)) {
125
+ out.push(` warning: ${file} procedures.${i}.actions.${j}.handoverReason: this runtime's release has no handover reasons, so the action hands over without "${a.handoverReason}" here; ${next}`)
126
+ }
127
+ })
128
+ }
129
+ // An End step that hands over, wherever it sits in the procedure's steps.
130
+ const endSteps = (sentSteps: unknown, keptSteps: unknown, path: string) => {
131
+ if (!Array.isArray(sentSteps) || !Array.isArray(keptSteps)) return
132
+ sentSteps.forEach((s, j) => {
133
+ const k = keptSteps[j]
134
+ if (!isRecord(s) || !isRecord(k)) return
135
+ const at = `${path}.${j}`
136
+ if (s.type === 'end' && typeof s.handoverReason === 'string' && !('handoverReason' in k)) {
137
+ out.push(` warning: ${file} ${at}.handoverReason: this runtime's release has no handover reasons, so the step hands over without "${s.handoverReason}" here; ${next}`)
138
+ }
139
+ endSteps(s.onFailure, k.onFailure, `${at}.onFailure`)
140
+ endSteps(s.otherwise, k.otherwise, `${at}.otherwise`)
141
+ if (Array.isArray(s.arms) && Array.isArray(k.arms)) {
142
+ s.arms.forEach((arm, ai) => {
143
+ const keptArm = (k.arms as unknown[])[ai]
144
+ if (isRecord(arm) && isRecord(keptArm)) endSteps(arm.steps, keptArm.steps, `${at}.arms.${ai}.steps`)
145
+ })
146
+ }
147
+ })
148
+ }
149
+ endSteps(p.steps, kept.steps, `procedures.${i}.steps`)
150
+ if (Array.isArray(p.blocks) && Array.isArray(kept.blocks)) {
151
+ p.blocks.forEach((b, bi) => {
152
+ const keptBlock = (kept.blocks as unknown[])[bi]
153
+ if (isRecord(b) && isRecord(keptBlock)) endSteps(b.steps, keptBlock.steps, `procedures.${i}.blocks.${bi}.steps`)
154
+ })
155
+ }
156
+ })
157
+ return out
158
+ }
159
+
67
160
  export async function applyOperatingConfig(args: {
68
161
  serviceUrl: string
69
162
  creds: LocalCredentials
@@ -111,9 +204,11 @@ export async function applyOperatingConfig(args: {
111
204
  }
112
205
  if (agent.status !== 200 || !id) return { ok: false, output: describeError(agent.status, agent.body) }
113
206
  const put = await call(`/v1/config/agents/${id}/operating`, { method: 'PUT', body: { operatingConfig } })
114
- if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body) }
207
+ if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body, args.slug) }
115
208
  const warnings = operatingIssueLines(args.slug, put.body)
116
- return { ok: true, output: warnings.join('\n'), ...(warnings.length ? { validationWarnings: warnings } : {}) }
209
+ // What this runtime's release left out is an adaptation to it, not a finding on the file.
210
+ const dropped = droppedSettingLines(args.slug, operatingConfig, (put.body as { data?: { draft?: unknown } } | null)?.data?.draft)
211
+ return { ok: true, output: [...warnings, ...dropped].join('\n'), ...(warnings.length ? { validationWarnings: warnings } : {}) }
117
212
  } catch (err) {
118
213
  return { ok: false, output: `error [UNAVAILABLE]: the local service did not answer: ${(err as Error).message}` }
119
214
  }
@@ -176,7 +176,7 @@ A Frontera project. Every file here is applied to a local runtime by
176
176
  | Path | What it is |
177
177
  |---|---|
178
178
  | \`agents/<slug>/agent.json\` | An Agent's draft: model, instructions, tools |
179
- | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks |
179
+ | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks, handover reasons |
180
180
  | \`agents/<slug>/bindings.json\` | What it may use: Functions, workflows, Blueprint object types |
181
181
  | \`agents/<slug>/evals/*.yaml\` | Its test cases |
182
182
  | \`workflows/<slug>.yaml\` | A workflow |
@@ -301,7 +301,7 @@ Frontera deployment.
301
301
  | Path | What it is |
302
302
  |---|---|
303
303
  | \`agents/<slug>/agent.json\` | An Agent's draft: model, instructions, tools |
304
- | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks |
304
+ | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks, handover reasons |
305
305
  | \`agents/<slug>/bindings.json\` | What it may use: the Functions and workflows it may start, the Blueprint object types it may query |
306
306
  | \`agents/<slug>/evals/*.yaml\` | Its test cases: a conversation and what a good reply does |
307
307
  | \`workflows/<slug>.yaml\` | A workflow |
@@ -6,6 +6,7 @@ import { BLUEPRINT_SCHEMA_FILES, BLUEPRINT_SCHEMAS } from './local-blueprint-sch
6
6
  import type { LocalProject } from './local-project'
7
7
  import { isAlive, readState } from './local-state'
8
8
  import { PROCEDURE_TOOL_FORMS, PROCEDURE_TOOL_GROUPS } from './local-agent-checks'
9
+ import { DEFAULT_HANDOVER_REASON_KEYS } from './local-operating'
9
10
  import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
10
11
 
11
12
  /**
@@ -888,6 +889,27 @@ function describeProjectFields(props: Record<string, unknown>): void {
888
889
  if ('dataScopes' in props && 'variables' in props) {
889
890
  describe(props, 'dataScopes', 'Optional. Narrows what the Agent reads of an object type to the records of the conversation: `[{ "objectType": "WorkOrder", "property": "customerId", "equals": "{{customer_id}}" }]`, at most one per object type. Every read of that type (query, fetch, aggregate, saved sets, links, media and metrics) sees only rows whose property equals the conversation variable; a conversation without the variable sees none. The variable is one in `variables`, and the property and variable are text or numbers. The object type must be granted in bindings.json `blueprint`. In an eval case, `variables` gives the conversation its values.')
890
891
  }
892
+ // Behavior settings (operating.json): why the Agent hands a conversation to people.
893
+ if ('handoverReasons' in props && 'variables' in props) {
894
+ const reasons = props.handoverReasons
895
+ const item = isRecord(reasons) && isRecord(reasons.items) ? reasons.items : null
896
+ if (isRecord(reasons) && item && isRecord(item.properties)) {
897
+ const inner = { ...item.properties }
898
+ describe(inner, 'key', 'The reason’s id: lower snake case (`order_problem`), at most 40 characters, unique in the list. Hand-over reports count by it, so keep it once the Agent is published; a handoff action names it in `handoverReason`.')
899
+ describe(inner, 'name', 'What people see in hand-over reports and the inbox (`Order problem`), 1 to 60 characters.')
900
+ describe(inner, 'whenToUse', 'What the Agent reads to choose this reason (`The customer reports a late, wrong or damaged order.`), 1 to 300 characters.')
901
+ props.handoverReasons = { ...reasons, items: { ...item, properties: inner } }
902
+ }
903
+ describe(props, 'handoverReasons', `Optional. Why the Agent hands a conversation to people; hand-over reports count by these. 1 to 15 reasons, each \`{ "key", "name", "whenToUse" }\`, keys unique, names unique ignoring case, and one must have the key \`other\` (rename it, never remove it). Leave the key out to use the default reasons (${DEFAULT_HANDOVER_REASON_KEYS.map((k) => `\`${k}\``).join(', ')}); a pull writes it only when the Agent has its own. \`frontera local dev\` refuses a list that breaks these rules, with each problem's path.`)
904
+ }
905
+ // A procedure's action (operating.json `procedures[].actions[]`): the reason a handoff records.
906
+ if ('handoverReason' in props && 'requiresFields' in props) {
907
+ describe(props, 'handoverReason', 'Optional, for a `handoff` action: the `key` of one of the Agent’s `handoverReasons` (the defaults when the file has none), recorded on the hand-over when a Step by step procedure runs the action. Absent: `other`. A key the Agent doesn’t have is reported by `frontera local dev` and refuses publishing.')
908
+ }
909
+ // A Step by step End step (operating.json `procedures[].blocks[].steps[]`): the reason one that hands over records.
910
+ if ('handoverReason' in props && 'outcome' in props) {
911
+ describe(props, 'handoverReason', 'Optional, for an End step with `outcome` `handoff`: the `key` of one of the Agent’s `handoverReasons` (the defaults when the file has none), recorded on the hand-over. Absent: `other`. A key the Agent doesn’t have is reported by `frontera local dev` and refuses publishing.')
912
+ }
891
913
  // An Agent (agent.json): the lists that name other resources.
892
914
  if ('modelConfigs' in props && 'knowledgeAttachments' in props) {
893
915
  describe(props, 'knowledgeAttachments', 'The knowledge bases the Agent searches: `[{ "knowledgeConfigId": "knowledge/<name>" }]`, one per base in knowledge/<name>/knowledge.json (`retrievalOverride` optional). `local dev` resolves each to this runtime’s id. When the key is present it is the Agent’s whole list (a base it leaves out is detached); leave the key out to keep the attachments made with `frontera knowledge attach`.', {