castle-web-cli 0.4.83 → 0.4.84

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.
@@ -0,0 +1,17 @@
1
+ export type FailureKind = "config" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
2
+ export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
3
+ export interface AgentFailure {
4
+ kind: FailureKind;
5
+ reason?: ConfigReason;
6
+ detail: string;
7
+ verbose?: string;
8
+ model?: string;
9
+ suggestion?: string;
10
+ }
11
+ export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
12
+ export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
13
+ export declare function failureCopy(opts: {
14
+ failure: AgentFailure;
15
+ spawnedTasks: boolean;
16
+ willRetry: boolean;
17
+ }): string;
@@ -0,0 +1,151 @@
1
+ // How an agent run failed, and what to tell the user about it.
2
+ //
3
+ // Split out of agent.ts (already ~3.8k lines) because the classification and
4
+ // the copy are one concern: the ONLY reason to distinguish failures is to give
5
+ // the user a different next action. The taxonomy is therefore organised by
6
+ // recovery, not by where the error came from:
7
+ //
8
+ // config - deterministic and won't fix itself (bad slug, bad key, no
9
+ // credits). Never retried: retrying is pure latency. Copy names
10
+ // what's wrong and points at settings / Castle.
11
+ // transient - the provider was busy or broke. NOT auto-retried either (see
12
+ // below), but the copy invites the user to send again.
13
+ // no-work - the model answered instead of working. Config is fine; the
14
+ // model is the problem.
15
+ // spawn - the CLI binary itself wouldn't start.
16
+ // timeout - we gave up waiting.
17
+ // exit - anything else, including unclassified. The safe default.
18
+ //
19
+ // On `transient` not auto-retrying: both paths ALREADY retried at the
20
+ // transport layer (native/openrouter.ts connectWithRetry does 3 backed-off
21
+ // connects on 429/5xx; the claude CLI retries internally). A turn-level retry
22
+ // on top stacks to ~6 connects and doubles the wait with the composer frozen,
23
+ // for no new information. A 429 that survived three backed-off attempts is not
24
+ // "one more go" territory.
25
+ // The single mapping from "OpenRouter said N" to a failure. Both paths funnel
26
+ // through it -- the native loop, which knows the status first-hand, and the
27
+ // claude-CLI path, which recovers it from text -- so the two can't drift into
28
+ // disagreeing about what a 402 means.
29
+ //
30
+ // `body` disambiguates the codes that mean more than one thing: OpenRouter
31
+ // returns 400/404 for a bad slug, an unreachable slug, and an oversized
32
+ // request alike.
33
+ export function failureForStatus(status, body, model) {
34
+ const detail = `HTTP ${status}${body ? `: ${body.slice(0, 200)}` : ""}`;
35
+ const config = (reason) => ({
36
+ kind: "config",
37
+ reason,
38
+ detail,
39
+ model,
40
+ verbose: `HTTP ${status}: ${body}`,
41
+ });
42
+ if (status === 401)
43
+ return config("bad-key");
44
+ if (status === 402)
45
+ return config("no-credits");
46
+ if (status === 403)
47
+ return config("flagged");
48
+ if (status === 429 || status >= 500) {
49
+ return { kind: "transient", detail, model, verbose: `HTTP ${status}: ${body}` };
50
+ }
51
+ if (status === 400 || status === 404) {
52
+ if (/no endpoints found|no allowed providers/i.test(body))
53
+ return config("no-endpoints");
54
+ if (/context length|context window|too many tokens/i.test(body)) {
55
+ return config("context-length");
56
+ }
57
+ if (/model[ _-]?not[ _-]?found|not a valid model|unknown model/i.test(body)) {
58
+ return config("unknown-model");
59
+ }
60
+ }
61
+ // Unrecognised: leave it unlabelled so the caller keeps its existing
62
+ // behavior rather than inventing a diagnosis.
63
+ return undefined;
64
+ }
65
+ // Provider errors reach the claude-CLI path as TEXT -- specifically in the
66
+ // stream-json `result` event with is_error:true and exit code 0, NOT stderr,
67
+ // which is typically empty (this is why firstErrorLine special-cases "agent
68
+ // exited 0"). Verified against the real binary pointed at both a local probe
69
+ // and real openrouter.ai; the shape is:
70
+ //
71
+ // API Error: 402 This request requires more credits, or fewer max_tokens...
72
+ // Failed to authenticate. API Error: 401 User not found.
73
+ //
74
+ // So the status code is right there in the text and does the classifying --
75
+ // the prose is only a tiebreaker for the codes that mean several things. The
76
+ // regex is unanchored because the CLI sometimes prefixes its own sentence.
77
+ const API_ERROR_RE = /API Error:\s*(\d{3})\b/i;
78
+ // Unrecognised text returns undefined, and the caller keeps today's "exit"
79
+ // behavior. That fallback is the whole safety story here: claude CLI's error
80
+ // prose is not a contract, so a miss must cost one wasted retry, never a wrong
81
+ // diagnosis. The deterministic failures don't depend on this at all --
82
+ // pre-flight catches them before a run starts.
83
+ //
84
+ // Recovering the status and handing off to failureForStatus (rather than
85
+ // re-deciding here) is what keeps the two paths honest: there is exactly one
86
+ // place that decides what a status means.
87
+ export function classifyProviderError(text, model) {
88
+ if (!text)
89
+ return undefined;
90
+ const status = Number(API_ERROR_RE.exec(text)?.[1] ?? NaN);
91
+ if (Number.isNaN(status))
92
+ return undefined;
93
+ return failureForStatus(status, text, model);
94
+ }
95
+ function quoted(model) {
96
+ return model ? `"${model}"` : "the model this session is set to";
97
+ }
98
+ // Plain-language copy for a failed run. Deliberately ONE table rather than a
99
+ // router copy and a task copy: the sentences would be near-identical, and the
100
+ // duplicate-detection gate (jscpd, threshold 0) is right to reject that.
101
+ //
102
+ // Voice matches the pre-existing copy: second person, no jargon, no status
103
+ // codes, and "the person running this session" for anything the user in the
104
+ // chat can't fix themselves. Every config case ends in an action.
105
+ function configCopy(failure) {
106
+ const model = quoted(failure.model);
107
+ switch (failure.reason) {
108
+ case "no-key":
109
+ return "This session isn't set up to use OpenRouter -- there's no API key. The person running this session needs to add one; reach out to Castle if you need help.";
110
+ case "bad-key":
111
+ return "OpenRouter rejected this session's API key. The person running this session needs to check it -- reach out to Castle if you need help.";
112
+ case "no-credits":
113
+ return "OpenRouter is out of credits for this session's key. Reach out to Castle to top it up, or switch to a different model in settings.";
114
+ case "unknown-model": {
115
+ const hint = failure.suggestion ? ` Did you mean "${failure.suggestion}"?` : "";
116
+ return `I can't use the model this session is set to -- OpenRouter doesn't recognize ${model}.${hint} Pick a different model in settings. If you think that model should work, reach out to Castle.`;
117
+ }
118
+ case "no-tools":
119
+ return `The model this session is set to (${model}) can't use tools, so it can't do work here. Pick a different model in settings.`;
120
+ case "no-endpoints":
121
+ return `OpenRouter knows ${model} but this session's key can't reach it. Pick a different model in settings, or reach out to Castle.`;
122
+ case "flagged":
123
+ return `${model} refused that request. Try rewording it, or switch to a different model in settings.`;
124
+ case "context-length":
125
+ return `That turn got too big for ${model} to hold. Try breaking it into smaller steps, or switch to a model with more room in settings.`;
126
+ default:
127
+ return "Something about this session's model setup isn't right. Check the model in settings, or reach out to Castle.";
128
+ }
129
+ }
130
+ export function failureCopy(opts) {
131
+ if (opts.willRetry) {
132
+ return "Something went wrong on my end -- give me a moment to try that again.";
133
+ }
134
+ const tasksNote = opts.spawnedTasks
135
+ ? " The steps I already kicked off are still running."
136
+ : "";
137
+ switch (opts.failure.kind) {
138
+ case "config":
139
+ return `${configCopy(opts.failure)}${tasksNote}`;
140
+ case "transient":
141
+ return `OpenRouter is busy right now and I couldn't get through. Send that again in a moment.${tasksNote}`;
142
+ case "no-work":
143
+ return `The model this session is set to (${quoted(opts.failure.model)}) answered instead of doing the work. Try a different model in settings -- if it keeps happening, reach out to Castle.${tasksNote}`;
144
+ case "spawn":
145
+ return `I couldn't start working on that -- something in this setup isn't right. If this keeps happening, the person running this session needs to take a look.${tasksNote}`;
146
+ case "timeout":
147
+ return `That took me too long and I had to stop partway. Send another message and I'll pick it back up.${tasksNote}`;
148
+ case "exit":
149
+ return `Something went wrong on my end partway through. Send another message and I'll pick it back up.${tasksNote}`;
150
+ }
151
+ }
package/dist/agent.d.ts CHANGED
@@ -1,11 +1,33 @@
1
1
  import * as http from "http";
2
2
  import { Duplex } from "stream";
3
+ import type { ORReasoningEffort, ORRoutingMode } from "./native/openrouter.js";
3
4
  export declare const AGENT_WS_PATH = "/__castle/agent";
4
5
  export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
5
6
  export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
6
7
  export declare const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
7
8
  export type AgentBackend = "cursor" | "claude" | "smith";
8
9
  export type ClaudeModel = "sonnet" | "opus" | "fable" | "openrouter";
10
+ interface AgentSettings {
11
+ router: AgentBackend;
12
+ tasks: AgentBackend;
13
+ routerClaudeModel: ClaudeModel;
14
+ tasksClaudeModel: ClaudeModel;
15
+ routerOpenrouterModel: string;
16
+ tasksOpenrouterModel: string;
17
+ routerReasoningEffort: ORReasoningEffort;
18
+ tasksReasoningEffort: ORReasoningEffort;
19
+ routerRouting: ORRoutingMode;
20
+ tasksRouting: ORRoutingMode;
21
+ routerProviderTier: string;
22
+ tasksProviderTier: string;
23
+ }
24
+ type OpenrouterSlugKey = "routerOpenrouterModel" | "tasksOpenrouterModel";
25
+ type SettingsWarnings = Partial<Record<OpenrouterSlugKey, {
26
+ model: string;
27
+ status: "unknown-model" | "no-tools";
28
+ message: string;
29
+ suggestion?: string;
30
+ }>>;
9
31
  type TaskStatus = "waiting" | "running" | "blocked" | "done" | "failed" | "interrupted";
10
32
  interface TaskRecord {
11
33
  id: string;
@@ -23,17 +45,21 @@ interface TaskRecord {
23
45
  originMessageId?: string;
24
46
  playtestFrames?: string[];
25
47
  resultSummary?: string;
48
+ errorCopy?: string;
49
+ errorDetail?: string;
26
50
  avatar?: string;
27
51
  phase?: string;
28
52
  acknowledged?: boolean;
29
53
  rejected?: boolean;
30
54
  blockedBy?: string[];
31
55
  }
56
+ export declare function roleUsesOpenrouter(backend: AgentBackend, claudeModel: ClaudeModel): boolean;
32
57
  export interface DepsState {
33
58
  kind: "ready" | "waiting" | "blocked";
34
59
  blockedBy?: string[];
35
60
  }
36
61
  export declare function classifyDeps(tasks: Map<string, TaskRecord>, task: TaskRecord): DepsState;
62
+ export declare function computeSettingsWarnings(settings: AgentSettings): Promise<SettingsWarnings>;
37
63
  export interface AgentServer {
38
64
  /** Attach the agent WebSocket if the upgrade targets the agent path. */
39
65
  handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): boolean;