@chatpanel/events 0.78.3 → 0.79.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.
@@ -36,8 +36,11 @@ function withTierAndMode(c) {
36
36
  }
37
37
 
38
38
  // Appoint one role → the best available candidate (or null if none usable).
39
- export function appoint(role, candidates, { overrides = {} } = {}) {
40
- const usable = (candidates || []).filter((c) => c && c.usable !== false && c.model);
39
+ export function appoint(role, candidates, { overrides = {}, exclude = null } = {}) {
40
+ // `exclude` ids (or model names) that failed this run: the next appointment is the
41
+ // nearest tier among what is left, which is what a person would do by hand.
42
+ const out = exclude ? new Set(exclude) : null;
43
+ const usable = (candidates || []).filter((c) => c && c.usable !== false && c.model && !(out && (out.has(c.id) || out.has(c.model))));
41
44
  if (!usable.length) return null;
42
45
  const ovId = overrides[role.id];
43
46
  if (ovId) {
package/index.js CHANGED
@@ -195,7 +195,7 @@ export { createBudget, validateBudget, normalizeBudget, usageOf, BudgetError, BU
195
195
  export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE, STARTER_TEAMS, starterTeams, blankTeam, teamFromForm, slugTeamName } from './team.js';
196
196
  export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
197
197
  export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS } from './team-board.js';
198
- export { runTeam, dryRunTeam, TeamRunError, RUN_STATUSES } from './team-run.js';
198
+ export { runTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
199
199
  export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
200
200
  export { teamLine, teamLanes } from './team-trail.js';
201
201
  export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.78.3",
3
+ "version": "0.79.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/team-run.js CHANGED
@@ -44,6 +44,16 @@ const strongestRole = (team) => [...team.roles].sort((a, b) => (TIER[b.prefer] ?
44
44
  * What a person sees before approving a run: every role with the model it would get, its
45
45
  * grants and mode; the plan when it is fixed (a planner plans at run time); the budget.
46
46
  */
47
+ /**
48
+ * Was this failure the MODEL being unreachable — not found, not deployed, no key, gone —
49
+ * rather than the request being wrong? The runner re-appoints on these and gives up on the
50
+ * rest. Provider wording varies; what they share is that a different model would answer.
51
+ */
52
+ export function isModelUnavailable(error) {
53
+ const m = String(error?.message || error || '');
54
+ return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403)\b|\b(404|401|403)\b/i.test(m);
55
+ }
56
+
47
57
  export function dryRunTeam(team, request, { appoint = null } = {}) {
48
58
  const t = normalizeTeam(team);
49
59
  const roles = t.roles.map((r) => {
@@ -85,7 +95,13 @@ export async function runTeam({
85
95
  const tasksOut = [];
86
96
  const say = (type, payload = {}) => emit(type, { runId: id, at: now(), ...payload });
87
97
  const roleOf = (rid) => t.roles.find((r) => r.id === rid);
88
- const modelFor = (r) => (appoint ? appoint(r) : null) || (r.model ? { model: r.model, mode: r.mode } : null);
98
+ // `exclude` holds what failed as unavailable this run; a re-appointment skips it. A role's
99
+ // pinned model is tried first and, when it is the one that failed, the roster steps in.
100
+ const modelFor = (r, exclude = null) => {
101
+ if (exclude?.size && r.model && exclude.has(r.model)) return appoint ? appoint({ ...r, model: undefined }, { exclude }) : null;
102
+ return (appoint ? appoint(r, { exclude }) : null) || (r.model && !exclude?.has(r.model) ? { model: r.model, mode: r.mode } : null);
103
+ };
104
+ const MAX_APPOINTMENTS = 3;
89
105
  const stopped = () => !!signal?.aborted;
90
106
 
91
107
  say('run.started', { team: t.name, request: String(request || ''), budget: t.budget, roles: t.roles.map((r) => r.id) });
@@ -132,8 +148,6 @@ export async function runTeam({
132
148
  const r = await runRecipe(role.recipe, { request: String(request || ''), task: task.prompt });
133
149
  text = typeof r === 'string' ? r : JSON.stringify(r);
134
150
  } else {
135
- const m = modelFor(role);
136
- if (!m?.model) throw new Error(`no model for role "${role.id}"`);
137
151
  // A call's tokens are unknown until it returns; what can be asked beforehand is
138
152
  // whether the budget is already exhausted and whether one more call is allowed.
139
153
  if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
@@ -141,16 +155,27 @@ export async function runTeam({
141
155
  const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
142
156
  // A host may build a toolset asynchronously (connecting MCP servers takes time).
143
157
  const tools = await toolsFor(role);
144
- const res = await callModel({
145
- runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
146
- system: role.prompt, prompt, tools, signal,
147
- onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
148
- });
149
- usage = res?.usage || null;
150
- if (usage) budget.charge(usage);
151
- if (res?.aborted) { status = 'stopped'; }
152
- else if (!res?.ok) throw new Error(res?.error || 'the model did not answer');
153
- text = String(res?.text || '');
158
+ // A model that is not there — not deployed, no key, gone — is not the task failing:
159
+ // the next model on the roster is appointed and the task tried again, up to three
160
+ // models. Anything else (a refusal, a timeout, a bad request) fails the task.
161
+ const exclude = new Set();
162
+ for (let attempt = 1; ; attempt++) {
163
+ const m = modelFor(role, exclude);
164
+ if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
165
+ if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude] });
166
+ const res = await callModel({
167
+ runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
168
+ system: role.prompt, prompt, tools, signal,
169
+ onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
170
+ });
171
+ usage = res?.usage || null;
172
+ if (usage) budget.charge(usage);
173
+ if (res?.aborted) { status = 'stopped'; break; }
174
+ if (res?.ok) { text = String(res?.text || ''); break; }
175
+ const err = res?.error || 'the model did not answer';
176
+ if (attempt >= MAX_APPOINTMENTS || stopped() || !isModelUnavailable(err)) throw new Error(err);
177
+ exclude.add(m.model);
178
+ }
154
179
  }
155
180
  } catch (e) {
156
181
  status = overBudget ? 'over-budget' : 'failed';
package/team-tool.js CHANGED
@@ -16,8 +16,12 @@ import { dryRunTeam } from './team-run.js';
16
16
 
17
17
  export const TEAM_TOOL_NAME = 'team';
18
18
 
19
+ // The teams the model may see and run: enabled, named, and whole. A record another client
20
+ // half-wrote (a name and nothing else) is not a team — Settings shows it for deleting.
21
+ const usable = (teams) => (teams || []).filter((t) => t?.name && t.enabled !== false && validateTeam(t).ok);
22
+
19
23
  function catalogue(teams) {
20
- const list = (teams || []).filter((t) => t && t.enabled !== false && t.name);
24
+ const list = usable(teams);
21
25
  if (!list.length) return 'No teams saved yet.';
22
26
  return `Saved teams: ${list.map((t) => `${t.name} (${(t.roles || []).map((r) => r.id).join(', ')})${t.description ? ` — ${t.description}` : ''}`).join('; ')}.`;
23
27
  }
@@ -66,9 +70,7 @@ const json = (v) => JSON.stringify(v);
66
70
  * @param saveTeam `async (team) => void`
67
71
  */
68
72
  export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null } = {}) {
69
- // A record another client half-wrote (a name and nothing else) is not a team: it is
70
- // neither offered to the model nor runnable, and Settings shows it for deleting.
71
- const byName = new Map((teams || []).filter((t) => t?.name && t.enabled !== false && validateTeam(t).ok).map((t) => [t.name, t]));
73
+ const byName = new Map(usable(teams).map((t) => [t.name, t]));
72
74
  let bound = null;
73
75
  return {
74
76
  id: 'team',
package/team-trail.js CHANGED
@@ -10,6 +10,7 @@ export function teamLine(ev) {
10
10
  case 'run.started': return { type: 'status', text: `team ${ev.team}: ${(ev.roles || []).join(', ')}` };
11
11
  case 'plan.ready': return { type: 'status', text: `plan: ${(ev.tasks || []).length} task${(ev.tasks || []).length === 1 ? '' : 's'} (${ev.by})` };
12
12
  case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}` };
13
+ case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable)` };
13
14
  case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
14
15
  case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
15
16
  case 'task.done': return { type: 'status', text: `${role} done · ${ev.findings || 0} finding${ev.findings === 1 ? '' : 's'}` };