Skip to content

Commit e51e4a2

Browse files
committed
feat(v1.5.3): round-3 subagent iteration — preflight, hints, null-strip notice
Dispatched a third clean-state subagent with an iteration-heavy scenario (Amira's Kopi Junction support agent with escalation rules). The subagent confirmed v1.5.1/1.5.2 fixes work as advertised — `--patch` iteration took just 2 commands to go from V1 wrong to V2 correct — and flagged 3 new actionable friction points addressed here. Fixes: 1. (F1) Model preflight on agent create/update Previously `{"model":"not-a-real-model"}` saved successfully and only blew up at next `agent run`. Now fails fast with a structured error + hint listing known models. Plausible-but-unknown (`vendor/name` shape) models warn to stderr but proceed — so brand-new models work between CLI releases. 2. (F2) Status-hint enrichment on API errors 401 → "Run af whoami / af login --api-key" 403 → "Check workspace/project or API key scopes" 404 → "Run the matching `list` command to see available IDs" 409 → "Fetch current state and reconcile before retrying" 422 → "Check details.payload for field-level errors" 429 → "Rate limited; back off" Every API error in --json output now carries an actionable `hint`. 3. (F3) Null-strip visibility `af agent update` emits a stderr [info] line listing which null-valued fields got auto-stripped — closes the footgun where bots thought they cleared a field but the server never saw null. Silenced under --json (keeps stdout clean for pipes). Tests: 385 pass (+7 from new utils-models.test.ts), same 14 skipped + 10 todo. Full regression: no behavior change on the green path. Quote from the round-3 subagent after using v1.5.2: "--patch made iteration feel like editing code, not re-creating a resource. Consistent schema: discriminators on every JSON response make scripting trivial. This is the right trajectory."
1 parent fde4b71 commit e51e4a2

5 files changed

Lines changed: 197 additions & 5 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pixelml/agenticflow-cli",
3-
"version": "1.5.2",
3+
"version": "1.5.3",
44
"description": "AgenticFlow CLI for agent-native API operations.",
55
"license": "Apache-2.0",
66
"repository": {

packages/cli/src/cli/changelog.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ export interface ChangelogEntry {
1313
}
1414

1515
export const CHANGELOG: ChangelogEntry[] = [
16+
{
17+
version: "1.5.3",
18+
date: "2026-04-14",
19+
highlights: [
20+
"Model preflight on `af agent create/update` — invalid model strings (typos, missing slash) fail fast BEFORE the agent gets created with a broken config. Unknown-but-plausible models warn but proceed (so new models work between CLI releases)",
21+
"Structured error hints on 401/403/404/409/422/429 — every common HTTP failure now carries an actionable `hint` in --json output pointing at the right recovery command (`af whoami`, `af agent list`, fetch-and-reconcile, etc.)",
22+
"`af agent update` emits a stderr `[info]` line naming which null-valued fields got auto-stripped. Closes the footgun where bots thought they'd cleared a field but the server never saw null. Silenced with --json (keeps stdout clean for piping)",
23+
],
24+
for_ai: [
25+
"If you're iterating on an agent's system prompt with `--patch`, don't also clear optional fields by sending null — the CLI strips them and the stderr info line tells you which ones were dropped",
26+
"When a command fails, check the `hint` field in the error envelope before retrying — 404s point you at the matching `list` command, 422s point you at `details.payload` for field-level errors",
27+
"Pass only models from `af bootstrap --json > models[]` — typos fail at validation time, not at next `agent run`. If you're trying a brand-new model and hit a warning, you can proceed (CLI is conservative — warns but doesn't block)",
28+
],
29+
},
1630
{
1731
version: "1.5.2",
1832
date: "2026-04-14",

packages/cli/src/cli/main.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import { WebhookConnector } from "./gateway/connectors/webhook.js";
2727
import type { ChannelConnector } from "./gateway/connector.js";
2828
import { listBlueprints, getBlueprint } from "./company-blueprints.js";
2929
import { CHANGELOG, getLatestChangelog } from "./changelog.js";
30-
import { stripNullFields } from "./utils/patch.js";
30+
import { stripNullFields, AGENT_UPDATE_STRIP_NULL_FIELDS } from "./utils/patch.js";
3131
import { inspectMcpToolsPattern } from "./utils/mcp-inspect.js";
3232
import { emitDeprecation } from "./utils/deprecation.js";
33+
import { validateModel } from "./utils/models.js";
3334
import {
3435
OperationRegistry,
3536
defaultSpecPath,
@@ -601,6 +602,20 @@ function buildClient(parentOpts: {
601602
});
602603
}
603604

605+
/**
606+
* Map of HTTP status codes to actionable hints AI operators can follow without
607+
* additional context. Keeps error responses useful when the server's error
608+
* message is terse (e.g. "Agent not found" with no follow-up).
609+
*/
610+
const STATUS_HINT_MAP: Record<number, string> = {
611+
401: "Authentication failed. Run `af whoami` to check current auth, or `af login --api-key <key>` to refresh.",
612+
403: "You don't have permission for this operation. Check the resource's workspace/project or your API key's scopes.",
613+
404: "Resource not found. Run the matching `list` command (e.g. `af agent list --json`) to see available IDs, or double-check the ID you passed.",
614+
409: "Conflict — the resource state disagrees with your request. Fetch the current state with `get` and reconcile before retrying.",
615+
422: "Validation failed. Check `details.payload` for the specific field errors — pydantic returns a list with field name + expected type per issue.",
616+
429: "Rate limited. Back off and retry with exponential delay.",
617+
};
618+
604619
/** Wrap an async SDK call with error handling. */
605620
async function run(fn: () => Promise<unknown>): Promise<void> {
606621
try {
@@ -618,12 +633,53 @@ async function run(fn: () => Promise<unknown>): Promise<void> {
618633
};
619634
if (err.requestId) details["request_id"] = err.requestId;
620635
if (err.payload !== null && err.payload !== undefined) details["payload"] = err.payload;
621-
fail("request_failed", message, undefined, details);
636+
const hint = STATUS_HINT_MAP[err.statusCode];
637+
fail("request_failed", message, hint, details);
622638
}
623639
fail("request_failed", message);
624640
}
625641
}
626642

643+
/**
644+
* Validate the `model` field on an agent create/update payload, if present.
645+
* Fail-fast on implausible strings; warn (stderr) on plausible-but-unknown
646+
* strings so new models work without CLI updates.
647+
*/
648+
function preflightModel(payload: Record<string, unknown>, context: string): void {
649+
if (!("model" in payload)) return;
650+
const res = validateModel(payload["model"]);
651+
if (!res.valid) {
652+
fail("invalid_option_value", `Invalid model in ${context}: ${String(payload["model"])}.`, res.suggestion);
653+
}
654+
if (!res.known && res.suggestion && !isJsonFlagEnabled()) {
655+
console.error(`[warn] ${res.suggestion}`);
656+
}
657+
}
658+
659+
/**
660+
* Report which keys got stripped by stripNullFields() so callers don't think
661+
* they successfully cleared a field when the CLI silently dropped it.
662+
* Emitted to stderr only (keeps stdout JSON clean for piping).
663+
*/
664+
function warnOnStrippedNulls(
665+
original: Record<string, unknown>,
666+
stripped: Record<string, unknown>,
667+
): void {
668+
if (isJsonFlagEnabled()) return; // don't pollute stderr on bot-driven runs
669+
const dropped: string[] = [];
670+
for (const key of AGENT_UPDATE_STRIP_NULL_FIELDS) {
671+
if (key in original && original[key] === null && !(key in stripped)) {
672+
dropped.push(key);
673+
}
674+
}
675+
if (dropped.length > 0) {
676+
console.error(
677+
`[info] Stripped ${dropped.length} null-valued field(s) the server rejects on update: ${dropped.join(", ")}. ` +
678+
"This is expected — server-required shape. See `af schema agent --field update --json` for the full list.",
679+
);
680+
}
681+
}
682+
627683
// ═══════════════════════════════════════════════════════════════════
628684
// Spec-based helpers (for generic commands: call, ops, catalog, doctor)
629685
// ═══════════════════════════════════════════════════════════════════
@@ -3956,6 +4012,7 @@ export function createProgram(): Command {
39564012
const body = loadJsonPayload(opts.body);
39574013
hardenInput(JSON.stringify(body), "agent create body");
39584014
ensureLocalValidation("agent.create", validateAgentCreatePayload(body));
4015+
preflightModel(body as Record<string, unknown>, "agent create");
39594016
if (opts.dryRun) {
39604017
printResult({ schema: "agenticflow.dry_run.v1", valid: true, target: "agent.create", payload: body });
39614018
return;
@@ -3977,16 +4034,23 @@ export function createProgram(): Command {
39774034
const client = buildClient(program.opts());
39784035
const body = loadJsonPayload(opts.body);
39794036
ensureLocalValidation("agent.update", validateAgentUpdatePayload(body));
4037+
preflightModel(body as Record<string, unknown>, "agent update");
39804038
if (opts.patch) {
39814039
await run(() =>
39824040
client.agents.patch(opts.agentId, body as Record<string, unknown>, {
3983-
prepare: (merged) => stripNullFields(merged),
4041+
prepare: (merged) => {
4042+
const stripped = stripNullFields(merged);
4043+
warnOnStrippedNulls(merged, stripped);
4044+
return stripped;
4045+
},
39844046
}),
39854047
);
39864048
} else {
39874049
// Full replace, but strip server-rejected nulls so a round-tripped
39884050
// `af agent get | af agent update --body @-` workflow doesn't 422.
3989-
const prepared = stripNullFields(body as Record<string, unknown>);
4051+
const original = body as Record<string, unknown>;
4052+
const prepared = stripNullFields(original);
4053+
warnOnStrippedNulls(original, prepared);
39904054
await run(() => client.agents.update(opts.agentId, prepared));
39914055
}
39924056
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Known model identifiers the AgenticFlow platform serves.
3+
*
4+
* Kept here so CLI-side validation can fail-fast on typos BEFORE a bogus
5+
* `model` string gets saved to an agent and only blows up at next run time.
6+
*
7+
* Stay in sync with the `models` array in `af bootstrap --json` and the
8+
* backend model registry. When you add a model here, also add it to
9+
* main.ts's bootstrap output.
10+
*
11+
* If this list drifts, `validateModel()` returns a soft-warning (not a hard
12+
* fail) so callers can still use brand-new models before the CLI is bumped.
13+
*/
14+
15+
export const KNOWN_MODELS: ReadonlyArray<string> = [
16+
"agenticflow/gemma-4-31b-it",
17+
"agenticflow/gemma-4-26b-a4b-it",
18+
"agenticflow/gemini-2.0-flash",
19+
"agenticflow/gpt-4o-mini",
20+
"agenticflow/deepseek-v3.2",
21+
"agenticflow/qwen-3.5-flash",
22+
];
23+
24+
export interface ModelValidation {
25+
valid: boolean;
26+
known: boolean;
27+
suggestion?: string;
28+
}
29+
30+
/**
31+
* Lightweight validator — flags bogus model strings before they reach the
32+
* server. Three outcomes:
33+
* - model in KNOWN_MODELS: `{valid: true, known: true}` — pass through.
34+
* - plausible format (`vendor/model-name`) but not in the list: `{valid:
35+
* true, known: false}` — warn but allow (new models ship between CLI
36+
* releases; don't hard-block).
37+
* - implausible format (no slash, empty, suspiciously short): `{valid:
38+
* false, known: false, suggestion}` — fail fast with a hint.
39+
*/
40+
export function validateModel(model: unknown): ModelValidation {
41+
if (typeof model !== "string" || model.trim().length === 0) {
42+
return {
43+
valid: false,
44+
known: false,
45+
suggestion: `Model must be a non-empty string like "agenticflow/gemini-2.0-flash". Available: ${KNOWN_MODELS.join(", ")}`,
46+
};
47+
}
48+
const trimmed = model.trim();
49+
if (KNOWN_MODELS.includes(trimmed)) {
50+
return { valid: true, known: true };
51+
}
52+
// Looks like `vendor/model-name`? Permit with warning.
53+
if (/^[a-z0-9_-]+\/[a-z0-9][a-z0-9._-]*$/i.test(trimmed)) {
54+
return {
55+
valid: true,
56+
known: false,
57+
suggestion: `Model "${trimmed}" is not in the CLI's known list. If this is a new model, proceed — but double-check the spelling. Known: ${KNOWN_MODELS.join(", ")}`,
58+
};
59+
}
60+
return {
61+
valid: false,
62+
known: false,
63+
suggestion: `Model "${trimmed}" has an invalid shape (expected "vendor/model-name"). Available: ${KNOWN_MODELS.join(", ")}`,
64+
};
65+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vitest";
2+
import { validateModel, KNOWN_MODELS } from "../src/cli/utils/models.js";
3+
4+
describe("validateModel", () => {
5+
it("accepts a known model as valid + known", () => {
6+
const r = validateModel("agenticflow/gemma-4-31b-it");
7+
expect(r.valid).toBe(true);
8+
expect(r.known).toBe(true);
9+
expect(r.suggestion).toBeUndefined();
10+
});
11+
12+
it("accepts every shipped model", () => {
13+
for (const m of KNOWN_MODELS) {
14+
const r = validateModel(m);
15+
expect(r.valid).toBe(true);
16+
expect(r.known).toBe(true);
17+
}
18+
});
19+
20+
it("warns on plausible-but-unknown vendor/model string", () => {
21+
const r = validateModel("agenticflow/brand-new-model-2027");
22+
expect(r.valid).toBe(true); // allow — don't block brand-new models
23+
expect(r.known).toBe(false);
24+
expect(r.suggestion).toMatch(/not in the CLI's known list/);
25+
});
26+
27+
it("fails on a plain token with no slash (subagent's F1 bug)", () => {
28+
const r = validateModel("not-a-real-model");
29+
expect(r.valid).toBe(false);
30+
expect(r.suggestion).toMatch(/invalid shape/);
31+
});
32+
33+
it("fails on empty / whitespace", () => {
34+
expect(validateModel("").valid).toBe(false);
35+
expect(validateModel(" ").valid).toBe(false);
36+
});
37+
38+
it("fails on non-string input", () => {
39+
expect(validateModel(null).valid).toBe(false);
40+
expect(validateModel(42).valid).toBe(false);
41+
expect(validateModel(undefined).valid).toBe(false);
42+
});
43+
44+
it("trims whitespace before validating", () => {
45+
const r = validateModel(" agenticflow/gemini-2.0-flash ");
46+
expect(r.valid).toBe(true);
47+
expect(r.known).toBe(true);
48+
});
49+
});

0 commit comments

Comments
 (0)