mixdog 1.3.0 → 1.3.1

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.
Files changed (103) hide show
  1. package/NOTICE.md +52 -9
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -1
  4. package/src/defaults/skills/setup/references/actions.md +4 -1
  5. package/src/defaults/skills/setup-guide/SKILL.md +10 -7
  6. package/src/output-styles/common.md +13 -8
  7. package/src/rules/agent-mode/AGENT_MODE.md +1 -1
  8. package/src/rules/agent-mode/MIXY.md +11 -4
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
  10. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
  12. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
  13. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
  14. package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
  18. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
  19. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
  20. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
  23. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
  24. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
  25. package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
  26. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
  27. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
  28. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
  29. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
  30. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
  31. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
  32. package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
  33. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
  34. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
  35. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
  36. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
  38. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
  39. package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
  40. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
  41. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
  42. package/src/runtime/attachments/store.mjs +6 -0
  43. package/src/runtime/effort-judge/judge-client.mjs +1 -1
  44. package/src/runtime/effort-judge/model-manifest.json +7 -7
  45. package/src/runtime/shared/agent-kind.mjs +152 -0
  46. package/src/runtime/shared/agent-route-config.d.mts +1 -0
  47. package/src/runtime/shared/agent-route-config.mjs +22 -8
  48. package/src/runtime/shared/agent-store.mjs +132 -0
  49. package/src/runtime/shared/bot-split-migration.mjs +457 -0
  50. package/src/runtime/shared/config-patch.mjs +20 -1
  51. package/src/runtime/shared/connector-catalog.d.mts +1 -2
  52. package/src/runtime/shared/connector-catalog.mjs +2 -4
  53. package/src/runtime/shared/connector-triggers.mjs +2 -0
  54. package/src/runtime/shared/err-text.mjs +3 -1
  55. package/src/runtime/shared/error-presentation.mjs +91 -6
  56. package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
  57. package/src/runtime/shared/handoff-artifacts.mjs +61 -0
  58. package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
  59. package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
  60. package/src/runtime/shared/model-route-fallback.mjs +79 -7
  61. package/src/runtime/shared/plain-preview.mjs +22 -17
  62. package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
  63. package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
  64. package/src/runtime/shared/usage-readout-totals.mjs +15 -13
  65. package/src/runtime/shared/user-data-guard.mjs +5 -2
  66. package/src/session-runtime/agent-home-delivery.mjs +423 -56
  67. package/src/session-runtime/agent-room-delivery.mjs +45 -4
  68. package/src/session-runtime/agent-task-sessions.mjs +299 -39
  69. package/src/session-runtime/boot/apis.mjs +13 -0
  70. package/src/session-runtime/boot/providers.mjs +14 -4
  71. package/src/session-runtime/config-lifecycle.mjs +43 -2
  72. package/src/session-runtime/handoff-attachments.mjs +593 -0
  73. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
  74. package/src/session-runtime/model-route/route-persist.mjs +2 -2
  75. package/src/session-runtime/model-route/route-tuning.mjs +7 -1
  76. package/src/session-runtime/model-route/set-route.mjs +21 -1
  77. package/src/session-runtime/native-web-search.mjs +2 -2
  78. package/src/session-runtime/new-session-config.mjs +6 -1
  79. package/src/session-runtime/route-resolution.mjs +5 -11
  80. package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
  81. package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
  82. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  83. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
  84. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  85. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  86. package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
  87. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
  88. package/src/session-runtime/tool-policy-refresh.mjs +13 -2
  89. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
  90. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
  91. package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
  92. package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
  93. package/src/standalone/daemon-session-control.mjs +13 -5
  94. package/src/standalone/daemon.mjs +16 -4
  95. package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
  96. package/src/standalone/session-service/agent-tree.mjs +4 -3
  97. package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
  98. package/src/tui/dist/index.mjs +70 -9
  99. package/src/tui/session/notice-surface.mjs +3 -4
  100. package/src/tui/session/session-api/routes.mjs +14 -3
  101. package/src/tui/session/transcript-intake.mjs +2 -0
  102. package/src/tui/session/turn-live-call.mjs +11 -3
  103. package/src/tui/session/turn.mjs +13 -3
@@ -335,8 +335,19 @@ const CODE_KINDS = new Map(
335
335
  quota_exhausted: ['quota_exceeded', 'quota_exhausted', 'usage_limit_reached', 'provider_accounts_exhausted'],
336
336
  context_overflow: ['context_length_exceeded', 'context_window_exceeded', 'model_context_window_exceeded'],
337
337
  request_too_large: ['request_too_large', 'payload_too_large', 'request_entity_too_large'],
338
- auth: ['invalid_api_key', 'authentication_error', 'unauthenticated', 'unauthorized', 'invalid_authentication'],
339
- permission: ['permission_error', 'permission_denied', 'forbidden'],
338
+ // Google names a rejected or expired key with an ErrorInfo reason on an
339
+ // INVALID_ARGUMENT 400; the reason outranks the envelope status.
340
+ auth: [
341
+ 'invalid_api_key',
342
+ 'authentication_error',
343
+ 'unauthenticated',
344
+ 'unauthorized',
345
+ 'invalid_authentication',
346
+ 'api_key_invalid',
347
+ 'api_key_expired',
348
+ 'access_token_expired',
349
+ ],
350
+ permission: ['permission_error', 'permission_denied', 'forbidden', 'service_disabled', 'api_key_service_blocked'],
340
351
  overloaded: [
341
352
  'server_is_overloaded',
342
353
  'overloaded_error',
@@ -374,6 +385,7 @@ const CODE_KINDS = new Map(
374
385
  'not_found',
375
386
  'not_found_error',
376
387
  'model_not_found',
388
+ 'model_not_supported',
377
389
  'unsupported_parameter',
378
390
  ],
379
391
  }).flatMap(([kind, codes]) => codes.map((code) => [code, kind]))
@@ -150,6 +150,13 @@ function isOverloadFailure(status, errorKind) {
150
150
  return status === 529 || errorKind === 'overloaded';
151
151
  }
152
152
 
153
+ function exceedsAdviceCeiling(caught, opts) {
154
+ const ceiling = Number(opts.maxRetryAfterMs);
155
+ if (!(ceiling > 0)) return false;
156
+ const advised = retryAfterMsFromError(caught);
157
+ return advised != null && advised > ceiling;
158
+ }
159
+
153
160
  // Decides whether `caught` may be re-issued: returns the error to throw (the
154
161
  // caught one, the exhausted-marked one on the last attempt, or the model
155
162
  // fallback trigger) or null when the next attempt may run — and only then
@@ -206,6 +213,16 @@ function retryVerdict(caught, { opts, attempt, maxAttempts, state }) {
206
213
  const typedQuota = QUOTA_KINDS.has(providerErrorKind(caught, { quotaFlags: false }).kind);
207
214
  if (typedQuota || opts.retry429 === false || status !== 429) return caught;
208
215
  }
216
+ // Opt-in `maxRetryAfterMs`: a server wait longer than the caller will sit
217
+ // through is a lock-out, not a retry. It surfaces now (with its advice for
218
+ // the notice and the account pool) instead of parking the send for it.
219
+ if (exceedsAdviceCeiling(caught, opts)) {
220
+ return markProviderRecoveryExhausted(caught, {
221
+ owner: opts.recoveryOwner || 'withRetry',
222
+ attempts: attempt + 1,
223
+ terminal: true,
224
+ });
225
+ }
209
226
  // Background calls (titles and the like) never retry an overload: during a
210
227
  // capacity cascade every retry multiplies gateway load, and nobody is
211
228
  // waiting on the result.
@@ -23,9 +23,11 @@ tool and brief each agent as the Lead rules describe.`;
23
23
  export const AGENT_MODE_DELEGATION = `# Delegation
24
24
 
25
25
  Handle conversation yourself. Delegate specialised work to sub-agents. Hand
26
- substantial coding to a new coding-mode task session (\`session create\`) with
26
+ substantial coding to a new agent task session (\`session create\`) with
27
27
  a self-contained brief: start it when already requested, otherwise suggest it. Then relay the
28
- result in your own words.`;
28
+ result in your own words. Work sent to an existing session (\`session send\`)
29
+ reports back only when its result says reportsBack: true; promise to report
30
+ only then.`;
29
31
 
30
32
  /** Mixy only: it also routes requests to the user's contact agents. */
31
33
  export const MIXY_CONTACTS_INTRO = `You also coordinate the user's contact agents: route a request to the
@@ -73,9 +73,9 @@ export const SESSION_TOOL = {
73
73
  openWorldHint: false,
74
74
  agentHidden: true,
75
75
  },
76
- trigger: 'Session lookup/control, coding task sessions, title, compact, fork, Project and worktrees.',
76
+ trigger: 'Session lookup/control, agent task sessions, title, compact, fork, Project and worktrees.',
77
77
  description:
78
- 'Inspect sessions or control another by exact ID, never a guessed one. send acknowledges queue intake, not completion. Cancel/delete and worktree actions require the user’s request. Other actions affect only the current session.',
78
+ 'Inspect sessions or control another by exact ID, never a guessed one. send acknowledges queue intake, not completion; promise a report of it only when its result has reportsBack: true. Cancel/delete and worktree actions require the user’s request. Other actions affect only the current session. Pass files on with attachments; a reported turn returns the files it produced as attachments.',
79
79
  inputSchema: {
80
80
  type: 'object',
81
81
  properties: {
@@ -102,7 +102,7 @@ export const SESSION_TOOL = {
102
102
  'prune-worktrees',
103
103
  ],
104
104
  description:
105
- 'status defaults to current; list pages sessions; read returns latest items; send queues prompt; cancel stops the turn; delete removes the session. create (agent conversations) starts a visible coding task session from brief and reports its turns back; unknown Project: list-projects first, still ambiguous: ask the user. compact runs after this turn; fork copies it without switching. merge-worktree merges into the Project’s checked-out branch; remove-worktree returns to the Project (repeat on WORKTREE_REMOVAL_INCOMPLETE); list-worktrees includes retaining sessions; prune-worktrees removes only clean, merged, unretained worktrees.',
105
+ 'status defaults to current; list pages sessions; read returns latest items; send queues prompt (reportsBack: the turn result reports back here); cancel stops the turn; delete removes the session. create (bot conversations) starts a visible agent task session from brief and reports its turns back; unknown Project: list-projects first, still ambiguous: ask the user. compact runs after this turn; fork copies it without switching. merge-worktree merges into the Project’s checked-out branch; remove-worktree returns to the Project (repeat on WORKTREE_REMOVAL_INCOMPLETE); list-worktrees includes retaining sessions; prune-worktrees removes only clean, merged, unretained worktrees.',
106
106
  },
107
107
  title: { type: 'string', description: 'rename: new current-session title; create: task title.' },
108
108
  session_id: {
@@ -147,8 +147,18 @@ export const SESSION_TOOL = {
147
147
  value: { type: 'boolean', description: 'favorite/archive: on or off.' },
148
148
  brief: { type: 'string', description: 'create: self-contained instruction, the first message.' },
149
149
  project: { type: 'string', description: 'create: registered Project name or existing directory.' },
150
- model: { type: 'string', description: 'create: provider/model or preset; default route otherwise.' },
150
+ model: {
151
+ type: 'string',
152
+ description: 'create: provider/model or preset; default is the Lead model (else the last-used one).',
153
+ },
151
154
  notify: { type: 'boolean', description: 'create: report results back, default true.' },
155
+ attachments: {
156
+ type: 'array',
157
+ items: { type: 'string' },
158
+ maxItems: 8,
159
+ description:
160
+ 'send/create: files the target turn receives as real attachments (images, PDF/Office, text): an attachment of this conversation by its file name (a user upload, e.g. its [Image: source: name]) or id (a reported file’s [attachment:id]), or a file path inside this session’s folders. Secret files are refused.',
161
+ },
152
162
  },
153
163
  additionalProperties: false,
154
164
  },
@@ -9,6 +9,15 @@ import { readJsonSafe, readTextSafe } from '../fs-utils.mjs';
9
9
  import { SHARED_SCAN_REVALIDATE_MS, createSharedScanCache } from './shared-scan-cache.mjs';
10
10
  import { isHiddenAgent } from '../../internal-agents.mjs';
11
11
  import { isAgentDisabled } from '../../../../shared/agent-route-config.mjs';
12
+ import { agentDefinitionKind } from '../../../../shared/agent-kind.mjs';
13
+ import {
14
+ BOTS_DIR,
15
+ MIXY_BOT_ID,
16
+ botStoreActive,
17
+ isStoreBot,
18
+ migratedBotIds,
19
+ userDefinitionDir,
20
+ } from '../../../../shared/agent-store.mjs';
12
21
  import {
13
22
  AGENT_DELETED_MARKER,
14
23
  AGENT_ROLE_IDS,
@@ -21,17 +30,22 @@ import {
21
30
  setAgentDefinitionCache,
22
31
  } from '../workflow-ids.mjs';
23
32
 
24
- // Yields its fs requests; see shared-scan-cache.mjs.
25
- function* scanCustomAgentIds(userRoot, shippedRoot) {
33
+ // Yields its fs requests; see shared-scan-cache.mjs. With the bot store
34
+ // active, bots/ is scanned too and the agents/ originals of the ids the split
35
+ // copied are skipped (agent-store.mjs).
36
+ function* scanCustomAgentIds(userRoot, shippedRoot, botsRoot = null, migrated = new Set()) {
26
37
  const ids = new Set();
27
- for (const root of [userRoot, shippedRoot]) {
38
+ for (const root of [...(botsRoot ? [botsRoot] : []), userRoot, shippedRoot]) {
28
39
  const entries = yield { op: 'readdir', path: root };
29
40
  for (const entry of entries) {
30
41
  if (!entry.isDirectory()) continue;
31
42
  const id = normalizeWorkflowId(entry.name);
32
- // `lead` is Mixy's own folder (PERSONA.md, AGENT.md), never an agent.
43
+ // `lead` is the Lead's own folder, `mixy` Mixy's: neither is an agent.
33
44
  if (!id || id === 'lead' || AGENT_ROLE_IDS.has(id) || isHiddenAgent(id)) continue;
34
- if (root !== userRoot && (yield { op: 'exists', path: join(userRoot, id, AGENT_DELETED_MARKER) })) continue;
45
+ if (root === botsRoot && id === MIXY_BOT_ID) continue;
46
+ if (root === userRoot && migrated.has(id)) continue;
47
+ if (root === botsRoot && entry.name !== id) continue;
48
+ if (root === shippedRoot && (yield { op: 'exists', path: join(userRoot, id, AGENT_DELETED_MARKER) })) continue;
35
49
  if (!(yield { op: 'exists', path: join(root, entry.name, 'AGENT.md') })) continue;
36
50
  ids.add(id);
37
51
  }
@@ -57,15 +71,24 @@ const agentListCache = createSharedScanCache();
57
71
 
58
72
  export function createWorkflowAgents({ rootDir, dataDir, readMarkdownDocument, normalizeAgentPermissionOrNone }) {
59
73
  function agentSourceDirs(dir, id) {
74
+ // A store bot reads only its own folder (agent-store.mjs).
75
+ if (isStoreBot(dir || dataDir, id)) return [userDefinitionDir(dir || dataDir, id)];
60
76
  const userDir = join(dir || dataDir, 'agents', id);
61
77
  if (existsSync(join(userDir, AGENT_DELETED_MARKER))) return [userDir];
62
78
  return [userDir, join(rootDir, 'agents', id)];
63
79
  }
64
80
 
65
81
  function agentScan(dir) {
66
- const userRoot = join(dir || dataDir, 'agents');
82
+ const base = dir || dataDir;
83
+ const userRoot = join(base, 'agents');
67
84
  const shippedRoot = join(rootDir, 'agents');
68
- return { key: `${userRoot}\n${shippedRoot}`, makeScan: () => scanCustomAgentIds(userRoot, shippedRoot) };
85
+ const store = botStoreActive(base);
86
+ const migrated = store ? migratedBotIds(base) : new Set();
87
+ const botsRoot = store ? join(base, BOTS_DIR) : null;
88
+ return {
89
+ key: `${userRoot}\n${shippedRoot}\n${botsRoot || ''}\n${[...migrated].join(',')}`,
90
+ makeScan: () => scanCustomAgentIds(userRoot, shippedRoot, botsRoot, migrated),
91
+ };
69
92
  }
70
93
 
71
94
  function listCustomAgentIds(dir) {
@@ -101,6 +124,13 @@ export function createWorkflowAgents({ rootDir, dataDir, readMarkdownDocument, n
101
124
  description: clean(manifest.description) || slot?.description || '',
102
125
  permission: normalizeAgentPermissionOrNone(doc.frontmatter.permission),
103
126
  frontmatter: doc.frontmatter,
127
+ // bot | agent | null, decided read-only (shared/agent-kind.mjs).
128
+ kind: agentDefinitionKind({
129
+ frontmatterKind: doc.frontmatter.kind,
130
+ ...(d !== join(rootDir, 'agents', agentId)
131
+ ? { dataDir: dir || dataDir, shippedRoot: rootDir, id: agentId, manifest, body }
132
+ : {}),
133
+ }),
104
134
  body,
105
135
  // Optional PERSONA.md beside AGENT.md (same dir, same cache entry).
106
136
  persona: (readTextSafe(join(d, 'PERSONA.md')) || '').trim(),
@@ -63,16 +63,18 @@ export function createWorkflowContext({ packs, agents }) {
63
63
  function leadPromptParts(config, dir, { agentMode = false, mixy = false } = {}) {
64
64
  const mode = configuredOrchestrationMode(config);
65
65
  const ids = agents.delegatableAgentIds(config, dir);
66
+ const isContact = (id) => isContactAgentId(id, agents.loadAgentDefinition(dir, id)?.kind);
66
67
  if (!agentMode) {
67
- const catalog = agentCatalog(dir, ids);
68
+ // Bots are never a coding Lead's agents: only Mixy hands them work.
69
+ const workflowIds = ids.filter((id) => !isContact(id));
70
+ const catalog = agentCatalog(dir, workflowIds);
68
71
  return {
69
72
  // Solo without agents has nothing to delegate to on request either.
70
- orchestrationContext: mode === 'none' && !ids.length ? '' : orchestrationInstructions(mode),
73
+ orchestrationContext: mode === 'none' && !workflowIds.length ? '' : orchestrationInstructions(mode),
71
74
  agentCatalogContext: catalog ? `# Available Agents\n\n${catalog}` : '',
72
75
  contactCatalogContext: '',
73
76
  };
74
77
  }
75
- const isContact = (id) => isContactAgentId(id, agents.loadAgentDefinition(dir, id)?.frontmatter?.kind);
76
78
  const subAgents = agentCatalog(
77
79
  dir,
78
80
  ids.filter((id) => !isContact(id))
@@ -29,6 +29,10 @@ export interface RoomRow {
29
29
  /** Host → log only: a user row's files as the composer sent them, which the
30
30
  * log's intake stores (agent-room-delivery.mjs) and replaces with the above. */
31
31
  upload?: { parts: unknown[]; pastedImages?: Record<string, unknown>; pastedTexts?: Record<string, unknown> };
32
+ /** Host → log only: a speech row's member session and the start of the
33
+ * turn that spoke; the log's intake stores the files that turn produced
34
+ * (agent-room-delivery.mjs) as the row's `parts` / `attachments`. */
35
+ artifactsFrom?: { sessionId: string; since: number };
32
36
  text: string;
33
37
  }
34
38
  /** A stored file reference (attachments/store.mjs): an image, a file, or an
@@ -6,9 +6,10 @@
6
6
  // (`sess_rmem_<room>_<agent>`) and is handed only the rows new since it last
7
7
  // spoke. What another agent said reaches a member as DATA inside an explicit
8
8
  // boundary, with every harness tag escaped and every line relabelled, so a peer
9
- // can never forge a control frame or pose as the user. Files come only with the
10
- // user's own rows: stored references, handed as content to the members the
11
- // message reaches, once each.
9
+ // can never forge a control frame or pose as the user. Files come with the
10
+ // user's own rows and with a member's speech (the files its turn produced,
11
+ // session-runtime/handoff-attachments.mjs): stored references, handed as
12
+ // content to the members the row reaches, once each.
12
13
  import { escapeHarnessTags } from '../../../shared/shell-output-summary.mjs';
13
14
  import { normalizeMessageAttachments, transcriptAttachmentFields } from '../../../shared/transcript-metadata.mjs';
14
15
  import { agentHomeSessionId } from './agent-home.mjs';
@@ -465,6 +466,12 @@ export function roomTurnPrompt({
465
466
  if (shareTools) {
466
467
  for (const tool of row.tools || []) lines.push(...peerLines(`(tool ${label(tool.name)}) ${tool.summary}`));
467
468
  }
469
+ // The files this member's turn produced follow its words, still data.
470
+ const files = storedParts(row.parts);
471
+ if (files.length) {
472
+ lines.push(...peerLines(`(files from ${label(row.name || row.speaker)} follow)`));
473
+ content.push({ type: 'text', text: lines.splice(0).join('\n') }, ...files);
474
+ }
468
475
  continue;
469
476
  }
470
477
  if (open) lines.push('</room-transcript>');
@@ -547,9 +554,11 @@ export function normalizeRoomRow(row = {}) {
547
554
  .map((target) => ({ id: cleanName(target.id), name: cleanName(target.name) }))
548
555
  .filter((target, index, list) => target.id && list.findIndex((other) => other.id === target.id) === index)
549
556
  .slice(0, ROOM_MAX_MEMBERS);
550
- // Files are the user's alone: what the log's intake stored for the row.
551
- const parts = row.kind === 'user' ? storedParts(row.parts) : [];
552
- const attachments = row.kind === 'user' ? normalizeMessageAttachments(row.attachments) : [];
557
+ // Files are the user's uploads or the files a member's turn produced: what
558
+ // the log's intake stored for the row.
559
+ const carriesFiles = row.kind === 'user' || row.kind === 'speech';
560
+ const parts = carriesFiles ? storedParts(row.parts) : [];
561
+ const attachments = carriesFiles ? normalizeMessageAttachments(row.attachments) : [];
553
562
  const images = row.kind === 'user' ? storedImages(row.images) : [];
554
563
  return {
555
564
  id,
@@ -623,8 +632,8 @@ export function roomRowItem(message, id) {
623
632
  room: { ...room, id: messageId },
624
633
  ...(messageId ? { messageId } : {}),
625
634
  ...(Number.isFinite(at) ? { at } : {}),
626
- // A user row's files, in the fields a chat prompt's row draws them from.
627
- ...(room.kind === 'user' ? transcriptAttachmentFields(message.meta.transcript) : {}),
635
+ // A user row's (or member speech's) files, in the fields a chat prompt's row draws them from.
636
+ ...(room.kind === 'user' || room.kind === 'speech' ? transcriptAttachmentFields(message.meta.transcript) : {}),
628
637
  ...(room.kind === 'user' && Array.isArray(images) && images.length ? { images } : {}),
629
638
  };
630
639
  }
@@ -5,13 +5,18 @@
5
5
  // turns an outbound call (outbound-policy.mjs) into an `ask` whatever the
6
6
  // hooks or the agent's permission said short of a deny, unless the agent's
7
7
  // permission for that action kind (store.mjs) is `allow`; `block` denies it
8
- // without a card. Other surfaces (group rooms) call it with their own
8
+ // without a card. The outbound rule applies ONLY while a bot acts (see
9
+ // actingBotId); an ordinary coding session's outbound call passes with the
10
+ // hook decision unchanged. Other surfaces (group rooms) call it with their own
9
11
  // decision to get the same outbound rule. After the user answered,
10
12
  // recordHumanDecision stores a "don't ask again" grant and the audit line. Unattended runs (schedule, webhook, room member) register a
11
13
  // port here (session-runtime/held-requests.mjs) that turns their asks into
12
14
  // held requests instead of an approval card.
13
15
  import { MIXY_AGENT_ID, agentIdOfHomeSession } from '../agent-home.mjs';
14
16
  import { serverConnector } from '../../mcp/server-connectors.mjs';
17
+ import { readAgentDefinitionKind } from '../../../../shared/agent-kind.mjs';
18
+ import { isContactAgentId } from '../../../../shared/agent-route-config.mjs';
19
+ import { mixdogRoot, resolvePluginData } from '../../../../shared/plugin-paths.mjs';
15
20
  import { roomMemberOfSession } from '../agent-room.mjs';
16
21
  import { classifyOutboundCall, isRememberableAction } from './outbound-policy.mjs';
17
22
  import { humanGateStore } from './store.mjs';
@@ -33,12 +38,33 @@ export function unattendedApprovalPort() {
33
38
  return unattendedPort;
34
39
  }
35
40
 
41
+ /** The agent a session is bound to: its unattended run's owner, a room
42
+ * member, a home thread's agent; '' for any other (coding) session. */
43
+ function boundAgentId(sessionId) {
44
+ const run = unattendedPort?.runOf?.(sessionId);
45
+ if (run?.agentId) return run.agentId;
46
+ return roomMemberOfSession(sessionId)?.agentId || agentIdOfHomeSession(sessionId) || '';
47
+ }
48
+
36
49
  /** The agent a session acts for: its unattended run's owner, a room member,
37
50
  * a home thread's agent, else Mixy. */
38
51
  export function agentIdForSession(sessionId) {
39
- const run = unattendedPort?.runOf?.(sessionId);
40
- if (run?.agentId) return run.agentId;
41
- return roomMemberOfSession(sessionId)?.agentId || agentIdOfHomeSession(sessionId) || MIXY_AGENT_ID;
52
+ return boundAgentId(sessionId) || MIXY_AGENT_ID;
53
+ }
54
+
55
+ /** The bot a session acts for, or '' when none: Mixy in her own home thread
56
+ * (or her unattended run / room member session), or a contact agent (`kind:
57
+ * bot`, isContactAgentId) in its home thread, room member session or
58
+ * unattended run. A coding Lead (also id 'lead'), its workflow agents and
59
+ * their sub-agent sessions are bound to nothing here, so they are no bot.
60
+ * A sub-agent of a bot has its own session id and no link back to the
61
+ * caller in this layer, so it is not gated either. */
62
+ function actingBotId(sessionId) {
63
+ const id = boundAgentId(sessionId);
64
+ if (!id) return '';
65
+ if (id === MIXY_AGENT_ID) return id;
66
+ const kind = readAgentDefinitionKind({ dataDir: resolvePluginData(), shippedRoot: mixdogRoot(), id });
67
+ return isContactAgentId(id, kind) ? id : '';
42
68
  }
43
69
 
44
70
  const actionOf = (decision) => String(decision?.action || decision?.decision || '').toLowerCase();
@@ -97,7 +123,8 @@ export async function humanGateDecision(call, decision, ctx = {}) {
97
123
  if (action === 'deny' || action === 'block') return { decision, request: null, gate: null };
98
124
  const agentId = agentIdForSession(ctx.sessionId);
99
125
  const annotations = ctx.annotations ?? (await mcpAnnotations(call?.name, ctx.mcpScopeId));
100
- const outbound = classifyOutboundCall({ name: call?.name, args: call?.args, annotations });
126
+ const classified = classifyOutboundCall({ name: call?.name, args: call?.args, annotations });
127
+ const outbound = classified && actingBotId(ctx.sessionId) ? classified : null;
101
128
  if (!outbound) {
102
129
  if (action !== 'ask') return { decision, request: null, gate: null };
103
130
  return {
@@ -8,6 +8,7 @@ import { resolveLiveToolCwd } from './tool-exec.mjs';
8
8
  import { createSteeringDrain } from './steering.mjs';
9
9
  import { createNoToolTurnResolver } from './no-tool-turn.mjs';
10
10
  import { classifyTerminationReason } from './termination.mjs';
11
+ import { routeUsageSegments } from './usage.mjs';
11
12
 
12
13
  // Consecutive identical-AND-failing tool calls (same name+args, error result)
13
14
  // tolerated across iterations before the loop refuses to re-execute and steers
@@ -153,5 +154,13 @@ export function finishLoop(state) {
153
154
  providerStateUpdated: state.providerStateUpdated,
154
155
  terminationReason: classifyTerminationReason(response, { sessionAgent: state.sessionAgent }),
155
156
  providerContinuations: state.noToolTurn.providerContinuations,
157
+ ...(state.routeUsageHops?.length
158
+ ? {
159
+ usageSegments: routeUsageSegments(state.routeUsageHops, state.lastUsage, {
160
+ provider: state.sessionRef?.provider,
161
+ model: state.sessionRef?.model,
162
+ }),
163
+ }
164
+ : {}),
156
165
  };
157
166
  }
@@ -1,4 +1,7 @@
1
- import { canFallbackRoute } from '../../../../shared/model-route-fallback.mjs';
1
+ import { routeFallbackVerdict } from '../../../../shared/model-route-fallback.mjs';
2
+ import { providerErrorKind } from '../../providers/provider-error-kind.mjs';
3
+ import { displayModelName } from '../../../../shared/model-display.mjs';
4
+ import { presentErrorText } from '../../../../shared/err-text.mjs';
2
5
  import { getProvider, initProviders } from '../../providers/registry.mjs';
3
6
  import { loadConfig } from '../../config.mjs';
4
7
  import { ensureProviderEnabled } from '../../runtime-core/config-helpers.mjs';
@@ -7,19 +10,107 @@ import { pinSessionWireRoute } from '../../providers/wire-route.mjs';
7
10
  import { buildRequestSendOptions } from '../manager/ask-turn-loop-options.mjs';
8
11
  import { prepareTurnEffortConfiguration } from '../../providers/effort-configuration.mjs';
9
12
  import { resolveSessionContextMeta } from '../manager/context-meta.mjs';
13
+ import { stripProviderReplayMessages } from '../manager/provider-replay.mjs';
14
+
15
+ // The short reason a notice gives for leaving a model, by provider kind.
16
+ const HOP_REASONS = {
17
+ quota_exhausted: 'usage limit reached',
18
+ entitlement: 'not included in the plan',
19
+ billing: 'out of credits',
20
+ rate_limited: 'rate-limited',
21
+ overloaded: 'unavailable',
22
+ transient: 'unavailable',
23
+ service_tier_unavailable: 'unavailable',
24
+ auth: 'sign-in failed',
25
+ permission: 'access denied',
26
+ invalid_request: 'model not available',
27
+ context_overflow: 'context too long',
28
+ };
29
+
30
+ const routeLabel = (route) => displayModelName(route.model, route.provider) || `${route.provider}/${route.model}`;
31
+
32
+ /** The muted transcript line for a hop: `<from> <reason> — switched to <to>.` */
33
+ export function routeFallbackNoticeText(from, to, error) {
34
+ const reason = HOP_REASONS[providerErrorKind(error).kind] || 'unavailable';
35
+ return `${routeLabel(from)} ${reason} — switched to ${routeLabel(to)}.`;
36
+ }
37
+
38
+ function routeFailure(route, error) {
39
+ return {
40
+ provider: route.provider,
41
+ model: route.model,
42
+ kind: providerErrorKind(error).kind,
43
+ error: presentErrorText(error),
44
+ };
45
+ }
46
+
47
+ // The chain ended after at least one hop: the final error keeps its own kind
48
+ // and names every model that was tried and why each failed. `skipped` is the
49
+ // next route when the chain stopped before it (a context overflow whose next
50
+ // window is no larger), so the text says why instead of "every model failed".
51
+ function annotateEndedChain(session, error, skipped = null) {
52
+ const tried = session?.routeFallbackFailures;
53
+ if (!tried?.length || !error || typeof error !== 'object' || error.routeFallbackAttempts) return;
54
+ const attempts = [...tried, routeFailure(session, error)];
55
+ try {
56
+ error.routeFallbackAttempts = attempts;
57
+ const lines = attempts.map((attempt) => `${routeLabel(attempt)}: ${attempt.error}`).join('; ');
58
+ const head = skipped
59
+ ? `Fallback chain stopped before ${routeLabel(skipped)} (its context window is not larger) — tried`
60
+ : 'Every model in the fallback chain failed —';
61
+ error.message = `${error.message}\n${head} ${lines}`;
62
+ if (skipped) error.routeFallbackStoppedBefore = { provider: skipped.provider, model: skipped.model };
63
+ } catch {
64
+ /* frozen errors keep their own message */
65
+ }
66
+ }
67
+
68
+ // A context overflow moves only to a model whose window is larger.
69
+ async function nextContextGrows(state, next) {
70
+ const config = ensureProviderEnabled(loadConfig(), next.provider);
71
+ await initProviders(config.providers, { signal: state.signal });
72
+ state.throwIfAborted();
73
+ const provider = getProvider(next.provider);
74
+ if (!provider) return false;
75
+ const current = resolveSessionContextMeta(state.provider, state.sessionRef.model, state.sessionRef).rawContextWindow;
76
+ const window = resolveSessionContextMeta(provider, next.model, next).rawContextWindow;
77
+ return Number(window) > Number(current || state.sessionRef.selectedContextWindow || 0);
78
+ }
10
79
 
11
80
  export async function advanceAgentRoute(state, error) {
12
81
  const session = state.sessionRef;
13
- const index = session?.routeFallbackIndex || 0;
14
- const next = session?.routeFallbacks?.[index];
15
- const committed = state.toolCallsTotal > 0 || state.response != null;
16
- if (!session?.agent || !next || !canFallbackRoute(error, { signal: state.signal, committed })) return false;
82
+ if (!session?.agent || !session.routeFallbacks?.length) return false;
83
+ const index = session.routeFallbackIndex || 0;
84
+ const next = session.routeFallbacks[index];
85
+ // Only THIS request must have produced nothing (its stream outcome says so):
86
+ // earlier requests of the turn may have run tools. Their calls and results
87
+ // are already in the live turn, which the next model continues from, so no
88
+ // completed tool runs again.
89
+ const verdict = routeFallbackVerdict(error, { signal: state.signal });
90
+ const hops = verdict === 'hop' || (verdict === 'context' && next && (await nextContextGrows(state, next)));
91
+ if (!next || !hops) {
92
+ if (verdict !== 'stay') annotateEndedChain(session, error, next || null);
93
+ return false;
94
+ }
17
95
  state.throwIfAborted();
18
96
  const config = ensureProviderEnabled(loadConfig(), next.provider);
19
97
  await initProviders(config.providers, { signal: state.signal });
20
98
  state.throwIfAborted();
21
99
  const provider = getProvider(next.provider);
22
100
  if (!provider) throw new Error(`Provider "${next.provider}" not available`);
101
+ const failed = routeFailure(session, error);
102
+ // The left model's share of the turn ends here: the running total at the
103
+ // switch bounds it, so each model's tokens are booked at its own rates.
104
+ state.routeUsageHops = [
105
+ ...(state.routeUsageHops || []),
106
+ { provider: session.provider, model: session.model, usage: state.lastUsage ? { ...state.lastUsage } : null },
107
+ ];
108
+ const notice = routeFallbackNoticeText(session, next, error);
109
+ // Saved with the new route: a crash before the live turn is checkpointed
110
+ // again recovers that turn without the previous provider's native state.
111
+ if (session.activeTurnCheckpoint && typeof session.activeTurnCheckpoint === 'object') {
112
+ session.activeTurnCheckpoint = { ...session.activeTurnCheckpoint, routeChanged: true };
113
+ }
23
114
  const oldOptions = buildRequestSendOptions({
24
115
  sessionId: state.sessionId,
25
116
  session,
@@ -41,9 +132,12 @@ export async function advanceAgentRoute(state, error) {
41
132
  );
42
133
  if (!updated) throw new Error('agent session closed during model fallback');
43
134
  updated.routeFallbackIndex = index + 1;
135
+ updated.routeFallbackFailures = [...(session.routeFallbackFailures || []), failed];
44
136
  await pinSessionWireRoute(updated, provider);
45
137
  state.throwIfAborted();
46
- state.messages.splice(0, state.messages.length, ...updated.messages);
138
+ // The live turn (prompt, assistant tool calls, tool results) carries on;
139
+ // only the previous provider's native replay state is dropped from it.
140
+ state.messages.splice(0, state.messages.length, ...stripProviderReplayMessages(state.messages));
47
141
  updated.liveTurnMessages = state.messages;
48
142
  state.sessionRef = updated;
49
143
  state.provider = provider;
@@ -67,6 +161,14 @@ export async function advanceAgentRoute(state, error) {
67
161
  signal: state.signal,
68
162
  })
69
163
  );
164
+ try {
165
+ process.stderr.write(
166
+ `[route-fallback] ${failed.provider}/${failed.model} (${failed.kind}) -> ${updated.provider}/${updated.model}\n`
167
+ );
168
+ state.opts.onStageChange?.('notice', { tone: 'info', message: notice });
169
+ } catch {
170
+ /* display-only */
171
+ }
70
172
  state.opts.onStageChange?.('connecting', {
71
173
  provider: updated.provider,
72
174
  model: updated.model,
@@ -7,7 +7,6 @@ import { traceCacheBreak } from '../../cache-break-trace.mjs';
7
7
  import { resolveLiveToolCwd } from './tool-exec.mjs';
8
8
  import { notifySkillsApplied, prepareExplicitSkills, recordAppliedSkills } from '../explicit-skills.mjs';
9
9
  import { addUsage, usageDeltaEvent } from './usage.mjs';
10
- import { isVisibleStreamProgress } from '../../../../shared/stream-progress.mjs';
11
10
  import { prepareProviderRequest } from './request-boundary.mjs';
12
11
  import { applyStepEffort } from './step-auto-effort.mjs';
13
12
  import { projectProviderRequest } from './request-projection.mjs';
@@ -182,15 +181,10 @@ export async function sendProviderRequest(state, round) {
182
181
  tools: round.sendTools,
183
182
  opts,
184
183
  });
185
- // The first visible token of this request, for its generation time (queue,
186
- // preflight and time-to-first-token stay out of the speed figures).
187
- let firstVisibleAt = 0;
188
- const callerOnStreamDelta = opts.onStreamDelta;
189
- const observeStreamDelta = (kind, ...rest) => {
190
- if (!firstVisibleAt && isVisibleStreamProgress(kind)) firstVisibleAt = Date.now();
191
- return callerOnStreamDelta?.(kind, ...rest);
192
- };
193
- opts.onStreamDelta = observeStreamDelta;
184
+ // The request's time for the speed figures, the wait for the first token
185
+ // included: the span the usage ledger recorded as `duration_ms` (stamped on
186
+ // the response); wall time only for a response no ledger row timed.
187
+ const requestStartedAt = Date.now();
194
188
  let result;
195
189
  try {
196
190
  result = await runWithProviderRequestToolsScope(round.requestToolScope, () =>
@@ -218,8 +212,6 @@ export async function sendProviderRequest(state, round) {
218
212
  sideQuestion?.discard();
219
213
  if (!(await advanceAgentRoute(state, error))) throw error;
220
214
  result = { action: 'retry_route' };
221
- } finally {
222
- if (opts.onStreamDelta === observeStreamDelta) opts.onStreamDelta = callerOnStreamDelta;
223
215
  }
224
216
  if (result.action === 'proceed') sideQuestion?.settle(result.response);
225
217
  else sideQuestion?.discard();
@@ -230,7 +222,9 @@ export async function sendProviderRequest(state, round) {
230
222
  prefixGuardCandidate,
231
223
  sendStartedAt,
232
224
  sendEndedAt,
233
- generationMs: firstVisibleAt ? Math.max(0, sendEndedAt - firstVisibleAt) : 0,
225
+ requestMs: Number.isFinite(result.response?.usageDurationMs)
226
+ ? Math.max(0, result.response.usageDurationMs)
227
+ : Math.max(0, sendEndedAt - requestStartedAt),
234
228
  preSendMs,
235
229
  toolResumeMs,
236
230
  };
@@ -443,7 +437,7 @@ export function settleSendResult(state, round, sent) {
443
437
  usage: reportedUsage,
444
438
  sendTools: round.sendTools,
445
439
  toolCalls: response.toolCalls?.length || 0,
446
- generationMs: sent.generationMs,
440
+ requestMs: sent.requestMs,
447
441
  })
448
442
  )
449
443
  );