@zibby/agent-workflow 2.0.5 → 2.0.7

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 CHANGED
@@ -167,6 +167,7 @@ If you want to compose Claude Code + Codex + Gemini into one pipeline with struc
167
167
  | `Graph` | The DAG. `addNode`, `addEdge`, `addConditionalEdges`, `setEntryPoint`. |
168
168
  | Fan-out | Call `addEdge` more than once from the same node and **every** branch runs, each carrying on through its own children. Branches run sequentially in declaration order (depth-first: a branch finishes before the next starts). Where branches converge, the shared node waits for all of them and runs **once** — see [Fan-out](#fan-out) below. |
169
169
  | `Node` | One agent invocation. Config: `prompt`, `outputSchema` (Zod), optional `agent`, `retries`, `skills`. |
170
+ | Retries | `retries: N` = N retries on ANY failure (N+1 attempts), no delay — declared per node. **On top of that**, a failure the engine can POSITIVELY identify as a transient provider failure (a dead stream, a 429, an upstream 5xx) buys up to `AGENT_TRANSIENT_RETRIES` extra attempts (default 2) with exponential backoff (`AGENT_TRANSIENT_BACKOFF_MS`, default 5000, 0 = no wait). One loop, one decider — `classifyFailure` in `failure-class.ts`. A failure it cannot positively identify is DETERMINISTIC and is never retried, because retrying a bug is an infinite loop that burns money. |
170
171
  | Sub-graph node | `addNode(name, { workflow: 'other-name', ... })` — dispatches another deployed workflow as a child. Sync (poll + merge) or async (`async: true`, fire-and-forget). See [Sub-graphs](#sub-graphs) below. |
171
172
  | `AgentStrategy` | Abstract base. Implement `canHandle(ctx)` and `invoke(prompt, opts)`. |
172
173
  | `registerStrategy()` | Tells the engine what agents are available. Selected by node `agent` field → `config.agents[name]` → `state.agentType`. |
@@ -0,0 +1,193 @@
1
+ /**
2
+ * FAILURE CLASSIFICATION — the ONE authority on "is this failure the provider
3
+ * having a bad second, or is it a real bug?"
4
+ *
5
+ * ─────────────────────────────────────────────────────────────────────────────
6
+ * WHY THIS FILE EXISTS (execution 42b920ae, 2026-08-25, the founder's own box)
7
+ *
8
+ * A `frontend-specialist` member had ALREADY built its feature, driven a real
9
+ * browser at it, and taken the screenshot ("Recently viewed … — newest-first
10
+ * ✓"). It was writing its handoff file when the provider's stream died:
11
+ *
12
+ * API Error: Stream idle timeout - partial response received
13
+ *
14
+ * Sixteen minutes of finished work were thrown away, no PR was pushed, and the
15
+ * ticket spent one of its two attempts. It later hit the cap and parked — so a
16
+ * network blip masqueraded as "the fleet cannot do this ticket".
17
+ *
18
+ * The stream dying is the provider's SDK and cannot be prevented. What happens
19
+ * NEXT is entirely ours, and it needs exactly one thing this codebase did not
20
+ * have: a way to tell a bad second apart from a bug. That is this file.
21
+ *
22
+ * ─────────────────────────────────────────────────────────────────────────────
23
+ * TWO RULES THIS FILE KEEPS, BOTH LEARNED THE EXPENSIVE WAY
24
+ *
25
+ * 1. **"Retry on anything I can't explain" is an infinite money pump.** A
26
+ * deterministic failure — a bad prompt, a schema violation, a missing
27
+ * credential, a node returning `{success:false}` — fails identically on
28
+ * every attempt, so a catch-all classifier turns one wasted turn into N.
29
+ * Therefore the default is DETERMINISTIC: a failure is retried only when
30
+ * something POSITIVELY identifies it as transient. Nothing about "I do not
31
+ * recognise this" is evidence of transience.
32
+ *
33
+ * 2. **The provider's own classification is a KIND, not a MESSAGE — and its
34
+ * catch-all is not a verdict.** The Claude Agent SDK stamps an error kind on
35
+ * the message (`authentication_failed` … `server_error` | `unknown`), and
36
+ * `unknown` is what a dead stream arrives as. Reading `unknown` as "transient"
37
+ * would smuggle rule 1's catch-all back in through the provider's front door;
38
+ * reading it as "deterministic" would leave the original bug unfixed. So an
39
+ * inconclusive kind DECIDES NOTHING and hands over to the text patterns —
40
+ * which name real network shapes and nothing else. A kind that IS conclusive
41
+ * is final: an `authentication_failed` whose text happens to say "timed out"
42
+ * must never be retried.
43
+ *
44
+ * ─────────────────────────────────────────────────────────────────────────────
45
+ * ONE DECIDER, THREE CALLERS. `classifyFailure` is consulted by:
46
+ * • `node.ts` — the ONE retry loop (there is no second one; see the long note
47
+ * in node.ts about the graph-level gate that was deleted for being one);
48
+ * • the strategies — via `formatProviderError`, so the message a failure
49
+ * carries and the classifier that reads it back are the SAME module and can
50
+ * never drift (🔗 TWO-PLACES);
51
+ * • the fleet's reconcile step — which reads a FINISHED run's `error` STRING
52
+ * off the execution record and must reach the same verdict the engine
53
+ * reached in-process. Hence: `classifyFailure` accepts an Error OR a string.
54
+ */
55
+ /** The failure classes. `inconclusive` is a verdict about the EVIDENCE, never about the failure. */
56
+ export type FailureClass = 'transient' | 'deterministic';
57
+ /**
58
+ * The Claude Agent SDK's own error-kind enum, each mapped to what it PROVES.
59
+ *
60
+ * Source of truth for the key set: the SDK's `assistant.error` / `api_retry.error`
61
+ * schema (one shared enum). Verified against the shipped CLI, 2026-08-25:
62
+ * ["authentication_failed","oauth_org_not_allowed","billing_error",
63
+ * "rate_limit","invalid_request","server_error","unknown","max_output_tokens"]
64
+ *
65
+ * A kind we have never seen is deliberately absent, and an absent kind is
66
+ * INCONCLUSIVE (never "transient") — a new provider kind must be classified by
67
+ * a human reading its meaning, not adopted into the retry budget by default.
68
+ */
69
+ export declare const PROVIDER_ERROR_KIND_CLASS: Readonly<Record<string, FailureClass | 'inconclusive'>>;
70
+ /**
71
+ * TRANSIENT TEXT SHAPES — an allowlist, not a heuristic.
72
+ *
73
+ * Every entry is a wire/stream failure that a second attempt can genuinely
74
+ * succeed at, and every entry was taken from a real error string (the CLI's own
75
+ * strings, undici/node network errors, HTTP status lines). Deliberately NOT
76
+ * here, because they repeat identically: prompt/context-length errors, credit
77
+ * balance, invalid API key, "No session token", zod validation output, a node
78
+ * returning `{success:false}`, and the engine's own `API stuck in loop` (a turn
79
+ * that looped once will loop again, and it is the most expensive thing to redo).
80
+ */
81
+ export declare const TRANSIENT_MESSAGE_PATTERNS: readonly RegExp[];
82
+ /**
83
+ * The message a strategy throws when the PROVIDER reported a failure.
84
+ *
85
+ * This exists because the alternative shipped, and it read — in the founder's
86
+ * execution record, as the entire explanation of a 16-minute loss —
87
+ *
88
+ * Node 'develop' failed after 1 attempt(s): unknown
89
+ *
90
+ * `unknown` was the SDK's error KIND. The strategy took it for the error
91
+ * MESSAGE and discarded the assistant text sitting in the same object, which
92
+ * said `API Error: Stream idle timeout - partial response received`. So this
93
+ * function keeps ALL THREE facts, and keeps the kind in a bracket so a later
94
+ * reader (the fleet's reconcile, which only ever sees the persisted string) can
95
+ * recover it without re-guessing. Formatter and parser in ONE module, on
96
+ * purpose.
97
+ */
98
+ export declare function formatProviderError({ kind, status, text }: {
99
+ kind?: string | null;
100
+ status?: number | string | null;
101
+ text?: string | null;
102
+ }): string;
103
+ /** The provider's error kind, from a structured field or from a formatted message. */
104
+ export declare function providerErrorKindOf(x: unknown): string | null;
105
+ /** The provider's HTTP status, from a structured field or from a formatted message. */
106
+ export declare function providerErrorStatusOf(x: unknown): number | null;
107
+ /**
108
+ * Is this failure worth one more attempt?
109
+ *
110
+ * Accepts an Error (in-process, from a strategy) OR a string (out-of-process:
111
+ * a finished run's `error` field off the execution record). Same verdict either
112
+ * way — that is the whole point of accepting both.
113
+ *
114
+ * ORDER IS THE DESIGN:
115
+ * 1. cancellation → deterministic, always, first. Never bill a stop twice.
116
+ * 2. conclusive provider kind → final. A conclusive kind OUTRANKS the text,
117
+ * so an auth failure that mentions a timeout is not retried.
118
+ * 3. provider HTTP status (429 / 5xx) → transient. Structured, unambiguous.
119
+ * 4. the text allowlist → transient only on a named network shape.
120
+ * 5. otherwise → deterministic. "I don't recognise this" is not evidence.
121
+ */
122
+ export declare function classifyFailure(x: unknown): FailureClass;
123
+ /** Convenience predicate over {@link classifyFailure}. */
124
+ export declare function isTransientFailure(x: unknown): boolean;
125
+ /** Default extra attempts a TRANSIENT failure may buy, on top of a node's declared `retries`. */
126
+ export declare const DEFAULT_TRANSIENT_RETRIES = 2;
127
+ /** Hard ceiling on the knob — a typo in an env var must not multiply the bill without limit. */
128
+ export declare const MAX_TRANSIENT_RETRIES = 5;
129
+ /**
130
+ * How many extra attempts a transient failure may buy.
131
+ *
132
+ * Bounded on purpose: a genuinely broken provider costs at most
133
+ * (1 + budget) turns per node instead of an unbounded stream of them. Brand
134
+ * neutral (CLAUDE.md § NEW identifiers) and `0` is a legal, honest "off".
135
+ */
136
+ export declare function transientRetryBudget(env?: Record<string, any>): number;
137
+ /** First-retry delay, doubling per retry. Overridable; `0` means "no wait". */
138
+ export declare const DEFAULT_TRANSIENT_BACKOFF_MS = 5000;
139
+ /** Ceiling on one wait, so a doubling series can never park a run for minutes. */
140
+ export declare const MAX_TRANSIENT_BACKOFF_MS = 30000;
141
+ /**
142
+ * Base delay for the backoff series. Brand-neutral knob; `0` disables the wait
143
+ * entirely (what a test wants, and what an operator on a private endpoint with
144
+ * no rate limit might legitimately want too).
145
+ */
146
+ export declare function transientBackoffBaseMs(env?: Record<string, any>): number;
147
+ /**
148
+ * Backoff before transient attempt `n` (1-based): 5s, 10s, 20s, capped at 30s,
149
+ * ±20% jitter so a fleet of members that all failed on the same upstream blip
150
+ * does not come back in lockstep.
151
+ */
152
+ export declare function transientBackoffMs(n: number, rand?: () => number, baseMs?: number): number;
153
+ /** What the budget says to do after one failed attempt. */
154
+ export interface AttemptDecision {
155
+ /** Run the node again? */
156
+ retry: boolean;
157
+ /** Which budget paid for it — `declared` = the node's own `retries`, `transient` = this file's. */
158
+ paidBy?: 'declared' | 'transient';
159
+ /** Sleep this long first (only ever non-zero for a TRANSIENT failure). */
160
+ delayMs: number;
161
+ /** The classification that produced this decision. */
162
+ failureClass: FailureClass;
163
+ /** 1-based index within `paidBy`'s budget, for the log line. */
164
+ index?: number;
165
+ /** Size of `paidBy`'s budget, for the log line. */
166
+ of?: number;
167
+ }
168
+ /**
169
+ * THE attempt budget — ONE implementation, shared by both of `node.ts`'s retry
170
+ * paths (custom-execute and LLM) so they can never drift into two policies.
171
+ *
172
+ * TWO budgets, spent in this order, and the order is the back-compat guarantee:
173
+ *
174
+ * 1. `retries` — the node's OWN declaration, honoured for ANY failure exactly
175
+ * as it always was. A node declaring `retries: 2` still gets 2 retries on a
176
+ * schema violation, with no delay. Nothing here changes that.
177
+ * 2. `transient` — extra attempts, available ONLY when `classifyFailure` says
178
+ * transient. This is the new capacity, and it is why a dead stream no
179
+ * longer destroys a member's finished work.
180
+ *
181
+ * A TRANSIENT failure gets the backoff sleep whichever budget pays for it —
182
+ * hammering an overloaded upstream three times in 200ms is not a retry policy.
183
+ */
184
+ export declare function createAttemptBudget(retries: number, { env, rand }?: {
185
+ env?: Record<string, any>;
186
+ rand?: () => number;
187
+ }): {
188
+ readonly attemptsMade: number;
189
+ readonly transientUsed: number;
190
+ readonly transientMax: number;
191
+ /** Record one failure and say whether to run again. */
192
+ next(failure: unknown): AttemptDecision;
193
+ };
@@ -0,0 +1 @@
1
+ var N=Object.freeze({authentication_failed:"deterministic",oauth_org_not_allowed:"deterministic",billing_error:"deterministic",invalid_request:"deterministic",max_output_tokens:"deterministic",rate_limit:"transient",server_error:"transient",unknown:"inconclusive"}),y=Object.freeze([/stream idle timeout/i,/partial response received/i,/no chunks received/i,/stream ended without receiving any events/i,/stream completed without receiving/i,/socket hang up/i,/premature close/i,/\b(?:ECONNRESET|ECONNABORTED|ECONNREFUSED|EPIPE|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|ENETRESET|EHOSTUNREACH)\b/,/\bfetch failed\b/i,/\bnetwork (?:error|timeout)\b/i,/request timed out/i,/\brequest timeout\b/i,/\boverloaded(?:_error)?\b/i,/\brate[_ ]limit(?:_error|ed)?\b/i,/\b(?:HTTP|http_status|status(?:\s*code)?)[:= ]\s*(?:429|5\d\d)\b/i,/\b(?:429|500|502|503|504|529)\s+(?:too many requests|internal server error|bad gateway|service unavailable|gateway time-?out|overloaded)\b/i]),_=Object.freeze([/\bAbortError\b/,/\bAPIUserAbortError\b/,/\boperation was aborted\b/i,/\bcanceled by parent abort\b/i,/\bstopped by operator\b/i]),m=/provider error \[([a-z_]+)(?:\s+http\s+(\d{3}))?\]/i;function v({kind:t,status:e,text:r}){let n=typeof t=="string"&&t.trim()?t.trim():"unclassified",i=Number(e),s=Number.isInteger(i)&&i>=100&&i<=599?` http ${i}`:"",o=(typeof r=="string"?r.trim():"")||"the provider reported a failure but gave no message";return`provider error [${n}${s}]: ${o}`}function d(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t!="object")return String(t);let e=t;return[e.message,e.name,e.code,e.providerErrorText,e.cause?.message??(typeof e.cause=="string"?e.cause:void 0),e.cause?.code].filter(n=>typeof n=="string"||typeof n=="number").join(" | ")}function g(t){if(t&&typeof t=="object"){let r=t.providerErrorKind;if(typeof r=="string"&&r.trim())return r.trim()}let e=m.exec(d(t));return e?e[1].toLowerCase():null}function T(t){if(t&&typeof t=="object"){let n=Number(t.providerErrorStatus);if(Number.isInteger(n)&&n>=100&&n<=599)return n}let e=m.exec(d(t)),r=e&&e[2]?Number(e[2]):NaN;return Number.isInteger(r)?r:null}function b(t){let e=d(t);if(!e)return"deterministic";for(let i of _)if(i.test(e))return"deterministic";let r=g(t);if(r){let i=N[r];if(i==="transient"||i==="deterministic")return i}let n=T(t);if(n===429||n>=500&&n<=599)return"transient";for(let i of y)if(i.test(e))return"transient";return"deterministic"}function S(t){return b(t)==="transient"}var l=2,R=5;function A(t=process.env){let e=t?.AGENT_TRANSIENT_RETRIES;if(e==null||String(e).trim()==="")return l;let r=Number(e);return!Number.isFinite(r)||r<0?l:Math.min(R,Math.floor(r))}var c=5e3,p=3e4;function M(t=process.env){let e=t?.AGENT_TRANSIENT_BACKOFF_MS;if(e==null||String(e).trim()==="")return c;let r=Number(e);return!Number.isFinite(r)||r<0?c:Math.min(p,Math.floor(r))}function f(t,e=Math.random,r=c){if(!(r>0))return 0;let n=Math.max(1,Math.floor(Number(t)||1)),i=Math.min(p,r*2**(n-1)),s=1+(e()-.5)*.4;return Math.max(1,Math.round(i*s))}function h(t,{env:e=process.env,rand:r=Math.random}={}){let n=Math.max(0,Math.floor(Number(t)||0)),i=A(e),s=M(e),a=0,o=0;return{get attemptsMade(){return 1+a+o},get transientUsed(){return o},get transientMax(){return i},next(E){let u=b(E);return a<n?(a+=1,{retry:!0,paidBy:"declared",delayMs:u==="transient"?f(a,r,s):0,failureClass:u,index:a,of:n}):u==="transient"&&o<i?(o+=1,{retry:!0,paidBy:"transient",delayMs:f(o,r,s),failureClass:u,index:o,of:i}):{retry:!1,delayMs:0,failureClass:u}}}}export{c as DEFAULT_TRANSIENT_BACKOFF_MS,l as DEFAULT_TRANSIENT_RETRIES,p as MAX_TRANSIENT_BACKOFF_MS,R as MAX_TRANSIENT_RETRIES,N as PROVIDER_ERROR_KIND_CLASS,y as TRANSIENT_MESSAGE_PATTERNS,b as classifyFailure,h as createAttemptBudget,v as formatProviderError,S as isTransientFailure,g as providerErrorKindOf,T as providerErrorStatusOf,M as transientBackoffBaseMs,f as transientBackoffMs,A as transientRetryBudget};