pilotswarm-sdk 0.3.1 → 0.3.3

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 (137) hide show
  1. package/dist/cms-migrations.d.ts.map +1 -1
  2. package/dist/cms-migrations.js +1090 -1
  3. package/dist/cms-migrations.js.map +1 -1
  4. package/dist/cms.d.ts +41 -1
  5. package/dist/cms.d.ts.map +1 -1
  6. package/dist/cms.js +51 -1
  7. package/dist/cms.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/inspect-tools.d.ts.map +1 -1
  11. package/dist/inspect-tools.js +27 -0
  12. package/dist/inspect-tools.js.map +1 -1
  13. package/dist/managed-session.d.ts +36 -0
  14. package/dist/managed-session.d.ts.map +1 -1
  15. package/dist/managed-session.js +317 -21
  16. package/dist/managed-session.js.map +1 -1
  17. package/dist/management-client.d.ts +36 -2
  18. package/dist/management-client.d.ts.map +1 -1
  19. package/dist/management-client.js +114 -2
  20. package/dist/management-client.js.map +1 -1
  21. package/dist/orchestration/index.d.ts +2 -2
  22. package/dist/orchestration/index.js +1 -1
  23. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  24. package/dist/orchestration/lifecycle.js +74 -4
  25. package/dist/orchestration/lifecycle.js.map +1 -1
  26. package/dist/orchestration/runtime.d.ts +1 -1
  27. package/dist/orchestration/state.d.ts +1 -0
  28. package/dist/orchestration/state.d.ts.map +1 -1
  29. package/dist/orchestration/state.js +1 -0
  30. package/dist/orchestration/state.js.map +1 -1
  31. package/dist/orchestration/turn.d.ts +24 -0
  32. package/dist/orchestration/turn.d.ts.map +1 -1
  33. package/dist/orchestration/turn.js +378 -167
  34. package/dist/orchestration/turn.js.map +1 -1
  35. package/dist/orchestration-registry.d.ts.map +1 -1
  36. package/dist/orchestration-registry.js +6 -2
  37. package/dist/orchestration-registry.js.map +1 -1
  38. package/dist/orchestration-version.d.ts +1 -1
  39. package/dist/orchestration-version.js +1 -1
  40. package/dist/orchestration.d.ts +2 -2
  41. package/dist/orchestration.js +1 -1
  42. package/dist/orchestration_1_0_46.d.ts +1 -1
  43. package/dist/orchestration_1_0_47.d.ts +1 -1
  44. package/dist/orchestration_1_0_48.d.ts +1 -1
  45. package/dist/orchestration_1_0_49.d.ts +1 -1
  46. package/dist/orchestration_1_0_50.d.ts +1 -1
  47. package/dist/orchestration_1_0_54/agents.d.ts +41 -0
  48. package/dist/orchestration_1_0_54/agents.d.ts.map +1 -0
  49. package/dist/orchestration_1_0_54/agents.js +758 -0
  50. package/dist/orchestration_1_0_54/agents.js.map +1 -0
  51. package/dist/orchestration_1_0_54/index.d.ts +24 -0
  52. package/dist/orchestration_1_0_54/index.d.ts.map +1 -0
  53. package/dist/orchestration_1_0_54/index.js +13 -0
  54. package/dist/orchestration_1_0_54/index.js.map +1 -0
  55. package/dist/orchestration_1_0_54/lifecycle.d.ts +41 -0
  56. package/dist/orchestration_1_0_54/lifecycle.d.ts.map +1 -0
  57. package/dist/orchestration_1_0_54/lifecycle.js +510 -0
  58. package/dist/orchestration_1_0_54/lifecycle.js.map +1 -0
  59. package/dist/orchestration_1_0_54/queue.d.ts +7 -0
  60. package/dist/orchestration_1_0_54/queue.d.ts.map +1 -0
  61. package/dist/orchestration_1_0_54/queue.js +644 -0
  62. package/dist/orchestration_1_0_54/queue.js.map +1 -0
  63. package/dist/orchestration_1_0_54/runtime.d.ts +29 -0
  64. package/dist/orchestration_1_0_54/runtime.d.ts.map +1 -0
  65. package/dist/orchestration_1_0_54/runtime.js +194 -0
  66. package/dist/orchestration_1_0_54/runtime.js.map +1 -0
  67. package/dist/orchestration_1_0_54/state.d.ts +125 -0
  68. package/dist/orchestration_1_0_54/state.d.ts.map +1 -0
  69. package/dist/orchestration_1_0_54/state.js +105 -0
  70. package/dist/orchestration_1_0_54/state.js.map +1 -0
  71. package/dist/orchestration_1_0_54/turn.d.ts +6 -0
  72. package/dist/orchestration_1_0_54/turn.d.ts.map +1 -0
  73. package/dist/orchestration_1_0_54/turn.js +864 -0
  74. package/dist/orchestration_1_0_54/turn.js.map +1 -0
  75. package/dist/orchestration_1_0_54/utils.d.ts +22 -0
  76. package/dist/orchestration_1_0_54/utils.d.ts.map +1 -0
  77. package/dist/orchestration_1_0_54/utils.js +226 -0
  78. package/dist/orchestration_1_0_54/utils.js.map +1 -0
  79. package/dist/orchestration_1_0_55/agents.d.ts +41 -0
  80. package/dist/orchestration_1_0_55/agents.d.ts.map +1 -0
  81. package/dist/orchestration_1_0_55/agents.js +758 -0
  82. package/dist/orchestration_1_0_55/agents.js.map +1 -0
  83. package/dist/orchestration_1_0_55/index.d.ts +24 -0
  84. package/dist/orchestration_1_0_55/index.d.ts.map +1 -0
  85. package/dist/orchestration_1_0_55/index.js +13 -0
  86. package/dist/orchestration_1_0_55/index.js.map +1 -0
  87. package/dist/orchestration_1_0_55/lifecycle.d.ts +41 -0
  88. package/dist/orchestration_1_0_55/lifecycle.d.ts.map +1 -0
  89. package/dist/orchestration_1_0_55/lifecycle.js +580 -0
  90. package/dist/orchestration_1_0_55/lifecycle.js.map +1 -0
  91. package/dist/orchestration_1_0_55/queue.d.ts +7 -0
  92. package/dist/orchestration_1_0_55/queue.d.ts.map +1 -0
  93. package/dist/orchestration_1_0_55/queue.js +644 -0
  94. package/dist/orchestration_1_0_55/queue.js.map +1 -0
  95. package/dist/orchestration_1_0_55/runtime.d.ts +29 -0
  96. package/dist/orchestration_1_0_55/runtime.d.ts.map +1 -0
  97. package/dist/orchestration_1_0_55/runtime.js +194 -0
  98. package/dist/orchestration_1_0_55/runtime.js.map +1 -0
  99. package/dist/orchestration_1_0_55/state.d.ts +126 -0
  100. package/dist/orchestration_1_0_55/state.d.ts.map +1 -0
  101. package/dist/orchestration_1_0_55/state.js +106 -0
  102. package/dist/orchestration_1_0_55/state.js.map +1 -0
  103. package/dist/orchestration_1_0_55/turn.d.ts +17 -0
  104. package/dist/orchestration_1_0_55/turn.d.ts.map +1 -0
  105. package/dist/orchestration_1_0_55/turn.js +921 -0
  106. package/dist/orchestration_1_0_55/turn.js.map +1 -0
  107. package/dist/orchestration_1_0_55/utils.d.ts +22 -0
  108. package/dist/orchestration_1_0_55/utils.d.ts.map +1 -0
  109. package/dist/orchestration_1_0_55/utils.js +226 -0
  110. package/dist/orchestration_1_0_55/utils.js.map +1 -0
  111. package/dist/session-manager.d.ts +24 -1
  112. package/dist/session-manager.d.ts.map +1 -1
  113. package/dist/session-manager.js +84 -0
  114. package/dist/session-manager.js.map +1 -1
  115. package/dist/session-proxy.d.ts +19 -0
  116. package/dist/session-proxy.d.ts.map +1 -1
  117. package/dist/session-proxy.js +172 -14
  118. package/dist/session-proxy.js.map +1 -1
  119. package/dist/sweeper-tools.d.ts +1 -1
  120. package/dist/sweeper-tools.d.ts.map +1 -1
  121. package/dist/sweeper-tools.js +20 -75
  122. package/dist/sweeper-tools.js.map +1 -1
  123. package/dist/system-agents.d.ts +2 -0
  124. package/dist/system-agents.d.ts.map +1 -1
  125. package/dist/system-agents.js +3 -0
  126. package/dist/system-agents.js.map +1 -1
  127. package/dist/types.d.ts +46 -1
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/types.js +10 -0
  130. package/dist/types.js.map +1 -1
  131. package/dist/worker.d.ts.map +1 -1
  132. package/dist/worker.js +10 -0
  133. package/dist/worker.js.map +1 -1
  134. package/package.json +3 -3
  135. package/plugins/mgmt/agents/agent-tuner.agent.md +36 -2
  136. package/plugins/mgmt/agents/sweeper.agent.md +7 -7
  137. package/plugins/system/agents/default.agent.md +3 -1
@@ -111,7 +111,7 @@ function acknowledgeTurnBoundary(action) {
111
111
  return `[SYSTEM: ${action} acknowledged. The runtime will suspend at the end of this turn. ` +
112
112
  `Finish any remaining tool results for the current step, then stop.]`;
113
113
  }
114
- const TERMINAL_TURN_BOUNDARY_ACTIONS = new Set(["wait", "input_required", "wait_for_agents", "list_sessions", "check_agents"]);
114
+ const TERMINAL_TURN_BOUNDARY_ACTIONS = new Set(["completed", "wait", "input_required", "wait_for_agents", "list_sessions", "check_agents"]);
115
115
  function hasTerminalTurnBoundary(turnState) {
116
116
  return turnState.pendingActions.some((action) => TERMINAL_TURN_BOUNDARY_ACTIONS.has(action.type));
117
117
  }
@@ -119,6 +119,77 @@ function blockedAfterTurnBoundary(toolName) {
119
119
  return `[SYSTEM: ${toolName} was not executed because a previous control tool already scheduled this turn to suspend. ` +
120
120
  `Stop now; the runtime will resume with the control-tool result.]`;
121
121
  }
122
+ function splitQualifiedModel(model) {
123
+ const configured = String(model || "").trim();
124
+ if (!configured)
125
+ return { provider: "(default)", model: "(default)" };
126
+ const separator = configured.indexOf(":");
127
+ if (separator <= 0)
128
+ return { provider: "(unqualified)", model: configured };
129
+ return {
130
+ provider: configured.slice(0, separator),
131
+ model: configured.slice(separator + 1),
132
+ };
133
+ }
134
+ function formatCurrentModelConfig(config) {
135
+ const configured = String(config.model || "").trim() || "(default)";
136
+ const { provider, model } = splitQualifiedModel(config.model);
137
+ const reasoningEffort = config.reasoningEffort ?? "(default)";
138
+ return [
139
+ "Current session configured model (this turn):",
140
+ `- provider: ${provider}`,
141
+ `- model: ${model}`,
142
+ `- qualified_model: ${configured}`,
143
+ `- reasoning_effort: ${reasoningEffort}`,
144
+ ].join("\n");
145
+ }
146
+ // ── Tool-call-as-text guard ──────────────────────────────────────
147
+ // Some models (observed on claude-opus-4.8, especially on repetitive keepalive
148
+ // cron cycles) intermittently emit a tool call as literal
149
+ // `<invoke name="...">`/`<parameter>` text inside the assistant message instead
150
+ // of a real tool_use block. That text is never executed, so a consequential
151
+ // call (store_fact, complete_agent, an ADO write, etc.) would be silently
152
+ // dropped while the transcript implies it happened. We detect the malformed
153
+ // text and re-prompt the model — bounded — to actually invoke the tool.
154
+ const MAX_TEXT_TOOL_CALL_CORRECTIONS = 2;
155
+ const TEXT_TOOL_CALL_INVOKE_RE = /<(?:antml:)?invoke\s+name\s*=\s*"([^"]+)"/i;
156
+ const TEXT_TOOL_CALL_STRUCTURE_RE = /<\/(?:antml:)?invoke\s*>|<(?:antml:)?parameter\b/i;
157
+ const FENCED_CODE_BLOCK_RE = /```[\s\S]*?```/g;
158
+ /**
159
+ * Detect a tool call the model emitted as literal text instead of a real
160
+ * tool_use block. Requires both the `<invoke name="...">` opener and a closing
161
+ * `</invoke>` or a `<parameter>` tag so prose that merely mentions the word
162
+ * "invoke" does not trip the guard. Returns the tool name, or null.
163
+ */
164
+ function detectTextEmittedToolCall(content) {
165
+ if (typeof content !== "string" || content.length === 0)
166
+ return null;
167
+ const withoutExamples = content.replace(FENCED_CODE_BLOCK_RE, "");
168
+ const nameMatch = withoutExamples.match(TEXT_TOOL_CALL_INVOKE_RE);
169
+ if (!nameMatch)
170
+ return null;
171
+ if (!TEXT_TOOL_CALL_STRUCTURE_RE.test(withoutExamples))
172
+ return null;
173
+ const beforeInvoke = withoutExamples.slice(0, nameMatch.index ?? 0).trim();
174
+ const afterClose = withoutExamples.replace(/[\s\S]*<\/(?:antml:)?invoke\s*>/i, "").trim();
175
+ if (afterClose)
176
+ return null;
177
+ // Allow the common one-token junk prefix observed from claude-opus-4.8
178
+ // (for example "court\n<invoke ...>") but do not flag explanatory prose,
179
+ // markdown docs, or examples that happen to contain Anthropic XML syntax.
180
+ if (beforeInvoke && beforeInvoke.split(/\s+/).length > 1)
181
+ return null;
182
+ return { toolName: (nameMatch[1] || "").trim() || "the requested tool", rawContent: content };
183
+ }
184
+ function buildTextEmittedToolCallCorrection(toolName) {
185
+ const named = toolName && toolName !== "the requested tool" ? `the "${toolName}" tool` : "the intended tool";
186
+ return `[SYSTEM: Tool-call protocol error. Your previous message contained a tool call written as literal text ` +
187
+ `(for example \`<invoke name="${toolName}">\` with <parameter> tags). Text formatted like that is NOT executed — ` +
188
+ `the tool did not run and produced no result, so anything you implied there has NOT actually happened. ` +
189
+ `Come to your senses and actually invoke ${named} now using the real tool-calling mechanism, not text. ` +
190
+ `Do not write <invoke> or <parameter> tags as message content. ` +
191
+ `If you did not actually need a tool this turn, reply with plain prose only and no tool-call markup.]`;
192
+ }
122
193
  function failureToolResult(error) {
123
194
  const message = error instanceof Error ? error.message : String(error ?? "Tool failed");
124
195
  return {
@@ -158,6 +229,12 @@ export class ManagedSession {
158
229
  sessionId;
159
230
  copilotSession;
160
231
  config;
232
+ /** Set for the duration of runTurn(); read by the lock-bypassing stop path. */
233
+ activeTurn = null;
234
+ /** Set only by requestStop(); classifies the turn unwind as "stopped". */
235
+ stopRequest = null;
236
+ /** Resolver for the current turn's completion promise — hang escalation hook. */
237
+ settleTurnResolver = null;
161
238
  constructor(sessionId, copilotSession, config) {
162
239
  this.sessionId = sessionId;
163
240
  this.copilotSession = copilotSession;
@@ -285,9 +362,9 @@ export class ManagedSession {
285
362
  handler: async () => "stub",
286
363
  });
287
364
  const reportCycleTool = defineTool("report_cycle", {
288
- description: "Report the outcome of the current recurring cron/cron_at watcher cycle. " +
289
- "Use status='quiet' when nothing material changed, status='material' when the parent should be notified, " +
290
- "and status='blocked' when the cycle found a blocker or failure that needs parent attention. " +
365
+ description: "Report the outcome of the current recurring cron/cron_at watcher cycle when something material happened. " +
366
+ "Use status='material' when the parent should be notified, and status='blocked' when the cycle found a blocker or failure that needs parent attention. " +
367
+ "On an uneventful cycle, prefer NOT calling this tool at all just end the turn silently; status='quiet' is accepted but unnecessary. " +
291
368
  "This tool does not end the turn; after calling it, finish normally. It is ignored outside recurring watcher cycles.",
292
369
  parameters: {
293
370
  type: "object",
@@ -303,6 +380,7 @@ export class ManagedSession {
303
380
  const listModelsTool = defineTool("list_available_models", {
304
381
  description: "List all available LLM models across all configured providers. " +
305
382
  "Returns each model's exact qualified name (provider:model), description, and cost tier. " +
383
+ "Also returns this session's current configured provider, model, and reasoning effort for the current turn. " +
306
384
  "This output is the authoritative source for model selection. " +
307
385
  "Use this when choosing the best model for a sub-agent task, or when the user asks about available models. " +
308
386
  "If you plan to pass spawn_agent(model=...), you must choose an exact provider:model value from this list and must not invent or shorten names. " +
@@ -315,13 +393,28 @@ export class ManagedSession {
315
393
  },
316
394
  handler: async () => "stub",
317
395
  });
396
+ const setSessionModelTool = defineTool("set_session_model", {
397
+ description: "Switch this session's model for the next turn boundary. " +
398
+ "Call list_available_models first and pass an exact provider:model value returned there. " +
399
+ "This ends the current turn. After it succeeds, stop; the runtime will continue on the selected model.",
400
+ parameters: {
401
+ type: "object",
402
+ properties: {
403
+ model: { type: "string", description: "Exact provider:model value from list_available_models." },
404
+ reasoning_effort: { type: "string", enum: ["low", "medium", "high", "xhigh"], description: "Optional reasoning effort supported by the selected model." },
405
+ },
406
+ required: ["model"],
407
+ },
408
+ handler: async () => "stub",
409
+ });
318
410
  const updateSessionSummaryTool = defineTool("update_session_summary", {
319
- description: "Update this session's short live summary for session lists, discovery, and the Summary tab. " +
411
+ description: "Update this session's short live summary and optionally set this session's sticky title for session lists, discovery, and the Summary tab. " +
320
412
  "Call it automatically after first meaningful work and after each notable update: changed intent, tangible progress toward the user's goal, received cross-session replies, delivered outputs, blockers, open questions, next actions, key links, schedule/delegate changes, or terminal state. " +
413
+ "Pass title when the user asks you to rename this session or when a durable human-readable title should stick; title updates lock the title against future automatic title summarization. " +
321
414
  "Keep it concise and scannable; use compact bullets or short Markdown tables for structured progress, comparisons, rankings, decisions, or result sets instead of prose blobs. " +
322
415
  "Do not paste long transcripts, raw logs, or bulky JSON into summary fields. " +
323
416
  "Do not call it for no-op heartbeats, timer wakes, or unchanged cron cycles. " +
324
- "Do not pass a string for summary_state. " + SESSION_SUMMARY_STATE_TEMPLATE,
417
+ "Do not pass a string for summary_state. summary_state is optional only when title is provided. " + SESSION_SUMMARY_STATE_TEMPLATE,
325
418
  parameters: {
326
419
  type: "object",
327
420
  properties: {
@@ -330,8 +423,8 @@ export class ManagedSession {
330
423
  description: "Structured live summary state. Must be an object, not a string. Missing arrays should be [].",
331
424
  },
332
425
  short_summary: { type: "string", description: "Optional concise summary for session lists. If omitted, summary_state.summary is used." },
426
+ title: { type: "string", description: "Optional sticky session title. When set, it behaves like a manual rename and prevents future automatic title changes." },
333
427
  },
334
- required: ["summary_state"],
335
428
  },
336
429
  handler: async () => "stub",
337
430
  });
@@ -368,7 +461,7 @@ export class ManagedSession {
368
461
  },
369
462
  handler: async () => "stub",
370
463
  });
371
- return [waitTool, waitOnWorkerTool, cronTool, cronAtTool, askUserTool, reportCycleTool, listModelsTool, updateSessionSummaryTool, sendSessionMessageTool, replySessionMessageTool];
464
+ return [waitTool, waitOnWorkerTool, cronTool, cronAtTool, askUserTool, reportCycleTool, listModelsTool, setSessionModelTool, updateSessionSummaryTool, sendSessionMessageTool, replySessionMessageTool];
372
465
  }
373
466
  /**
374
467
  * Sub-agent tool definitions.
@@ -578,8 +671,73 @@ export class ManagedSession {
578
671
  *
579
672
  * Similarly, if onUserInputRequest fires, we abort and return
580
673
  * "input_required" so the orchestration can wait for the user's answer.
674
+ *
675
+ * Stop classification: a user stop (requestStop → abort) reclassifies the
676
+ * unwind as `{ type: "stopped" }` regardless of how the inner turn settled
677
+ * — checked BEFORE pendingActions so a stop that races a wait()/ask_user
678
+ * control-tool abort wins instead of being swallowed into a durable timer,
679
+ * and applied to error unwinds so a forced settle/disconnect is not
680
+ * misclassified as a retryable error.
581
681
  */
582
682
  async runTurn(prompt, opts) {
683
+ this.activeTurn = { turnIndex: opts?.turnIndex ?? -1, startedAt: Date.now() };
684
+ try {
685
+ const result = await this._runTurnInner(prompt, opts);
686
+ if (this.stopRequest) {
687
+ return {
688
+ type: "stopped",
689
+ reason: this.stopRequest.reason,
690
+ ...(result?.events ? { events: result.events } : {}),
691
+ };
692
+ }
693
+ return result;
694
+ }
695
+ catch (err) {
696
+ if (this.stopRequest) {
697
+ return { type: "stopped", reason: this.stopRequest.reason };
698
+ }
699
+ throw err;
700
+ }
701
+ finally {
702
+ this.activeTurn = null;
703
+ this.stopRequest = null;
704
+ this.settleTurnResolver = null;
705
+ }
706
+ }
707
+ /** The in-flight turn, if any. Read by the lock-bypassing stop path. */
708
+ getActiveTurn() {
709
+ return this.activeTurn;
710
+ }
711
+ /**
712
+ * Mark the in-flight turn as user-stopped so its unwind classifies as
713
+ * `stopped`. Returns the active turn info, or null when no turn is
714
+ * running. Does NOT abort by itself — callers pair this with abort().
715
+ */
716
+ requestStop(reason) {
717
+ if (!this.activeTurn)
718
+ return null;
719
+ this.stopRequest = { reason, requestedAt: Date.now() };
720
+ return { turnIndex: this.activeTurn.turnIndex };
721
+ }
722
+ /**
723
+ * Hang escalation: resolve the current turn's completion promise directly.
724
+ * runTurn() settles only on the SDK's `session.idle` event; if a wedged
725
+ * stream never fires it, this forces the unwind without depending on any
726
+ * further SDK behavior. Pair with requestStop() so the unwind classifies
727
+ * as `stopped`. Returns false when no turn is in flight.
728
+ */
729
+ forceSettleTurn(reason) {
730
+ if (!this.activeTurn)
731
+ return false;
732
+ if (!this.stopRequest)
733
+ this.stopRequest = { reason, requestedAt: Date.now() };
734
+ try {
735
+ this.settleTurnResolver?.();
736
+ }
737
+ catch { }
738
+ return true;
739
+ }
740
+ async _runTurnInner(prompt, opts) {
583
741
  const turnState = {
584
742
  pendingActions: [],
585
743
  queuedActions: [],
@@ -637,9 +795,9 @@ export class ManagedSession {
637
795
  },
638
796
  });
639
797
  const reportCycleTool = defineTool("report_cycle", {
640
- description: "Report the outcome of the current recurring cron/cron_at watcher cycle. " +
641
- "Use status='quiet' when nothing material changed, status='material' when the parent should be notified, " +
642
- "and status='blocked' when the cycle found a blocker or failure that needs parent attention. " +
798
+ description: "Report the outcome of the current recurring cron/cron_at watcher cycle when something material happened. " +
799
+ "Use status='material' when the parent should be notified, and status='blocked' when the cycle found a blocker or failure that needs parent attention. " +
800
+ "On an uneventful cycle, prefer NOT calling this tool at all just end the turn silently; status='quiet' is accepted but unnecessary. " +
643
801
  "This tool does not end the turn; after calling it, finish normally. It is ignored outside recurring watcher cycles.",
644
802
  parameters: {
645
803
  type: "object",
@@ -892,6 +1050,7 @@ export class ManagedSession {
892
1050
  const listModelsTool = defineTool("list_available_models", {
893
1051
  description: "List all available LLM models across all configured providers. " +
894
1052
  "Returns each model's exact qualified name (provider:model), description, and cost tier. " +
1053
+ "Also returns this session's current configured provider, model, and reasoning effort for the current turn. " +
895
1054
  "This output is the authoritative source for model selection. " +
896
1055
  "Use this when choosing the best model for a sub-agent task, or when the user asks about available models. " +
897
1056
  "If you plan to pass spawn_agent(model=...), you must choose an exact provider:model value from this list and must not invent or shorten names. " +
@@ -903,16 +1062,62 @@ export class ManagedSession {
903
1062
  properties: {},
904
1063
  },
905
1064
  handler: async () => {
906
- return opts?.modelSummary || "No model providers configured.";
1065
+ return [
1066
+ formatCurrentModelConfig(this.config),
1067
+ opts?.modelSummary || "No model providers configured.",
1068
+ ].join("\n\n");
1069
+ },
1070
+ });
1071
+ const setSessionModelTool = defineTool("set_session_model", {
1072
+ description: "Switch this session's model for the next turn boundary. " +
1073
+ "Call list_available_models first and pass an exact provider:model value returned there. " +
1074
+ "This ends the current turn. After it succeeds, stop; the runtime will continue on the selected model.",
1075
+ parameters: {
1076
+ type: "object",
1077
+ properties: {
1078
+ model: { type: "string", description: "Exact provider:model value from list_available_models." },
1079
+ reasoning_effort: { type: "string", enum: ["low", "medium", "high", "xhigh"], description: "Optional reasoning effort supported by the selected model." },
1080
+ },
1081
+ required: ["model"],
1082
+ },
1083
+ handler: async (args) => {
1084
+ if (hasTerminalTurnBoundary(turnState))
1085
+ return blockedAfterTurnBoundary("set_session_model");
1086
+ const model = String(args.model || "").trim();
1087
+ if (!model)
1088
+ return "Error: model is required.";
1089
+ const reasoningEffort = args.reasoning_effort ? normalizeReasoningEffort(args.reasoning_effort) : undefined;
1090
+ if (args.reasoning_effort && !reasoningEffort) {
1091
+ return "Error: reasoning_effort must be one of low, medium, high, xhigh.";
1092
+ }
1093
+ if (!controlBridge)
1094
+ return "Error: set_session_model is unavailable in this session.";
1095
+ const result = await controlBridge.setSessionModel({ model, ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}) });
1096
+ if (/model switch accepted/i.test(String(result))) {
1097
+ turnState.pendingActions.push({
1098
+ type: "completed",
1099
+ content: "Model switch requested. Continuing on the selected model.",
1100
+ });
1101
+ return `${result}\n${acknowledgeTurnBoundary("set_session_model")}`;
1102
+ }
1103
+ if (/set_session_model failed/i.test(String(result)) || !/model switch accepted/i.test(String(result))) {
1104
+ turnState.pendingActions.push({
1105
+ type: "completed",
1106
+ content: "Model switch failed. Continuing on the unchanged model.",
1107
+ });
1108
+ return `${result}\n${acknowledgeTurnBoundary("set_session_model")}`;
1109
+ }
1110
+ return result;
907
1111
  },
908
1112
  });
909
1113
  const updateSessionSummaryTool = defineTool("update_session_summary", {
910
- description: "Update this session's short live summary for session lists, discovery, and the Summary tab. " +
1114
+ description: "Update this session's short live summary and optionally set this session's sticky title for session lists, discovery, and the Summary tab. " +
911
1115
  "Call it automatically after first meaningful work and after each notable update: changed intent, tangible progress toward the user's goal, received cross-session replies, delivered outputs, blockers, open questions, next actions, key links, schedule/delegate changes, or terminal state. " +
1116
+ "Pass title when the user asks you to rename this session or when a durable human-readable title should stick; title updates lock the title against future automatic title summarization. " +
912
1117
  "Keep it concise and scannable; use compact bullets or short Markdown tables for structured progress, comparisons, rankings, decisions, or result sets instead of prose blobs. " +
913
1118
  "Do not paste long transcripts, raw logs, or bulky JSON into summary fields. " +
914
1119
  "Do not call it for no-op heartbeats, timer wakes, or unchanged cron cycles. " +
915
- "Do not pass a string for summary_state. " + SESSION_SUMMARY_STATE_TEMPLATE,
1120
+ "Do not pass a string for summary_state. summary_state is optional only when title is provided. " + SESSION_SUMMARY_STATE_TEMPLATE,
916
1121
  parameters: {
917
1122
  type: "object",
918
1123
  properties: {
@@ -921,8 +1126,8 @@ export class ManagedSession {
921
1126
  description: "Structured live summary state. Must be an object, not a string. Missing arrays should be [].",
922
1127
  },
923
1128
  short_summary: { type: "string", description: "Optional concise summary for session lists. If omitted, summary_state.summary is used." },
1129
+ title: { type: "string", description: "Optional sticky session title. When set, it behaves like a manual rename and prevents future automatic title changes." },
924
1130
  },
925
- required: ["summary_state"],
926
1131
  },
927
1132
  handler: async (args) => {
928
1133
  if (hasTerminalTurnBoundary(turnState))
@@ -1271,7 +1476,7 @@ export class ManagedSession {
1271
1476
  return acknowledgeTurnBoundary("delete_agent");
1272
1477
  },
1273
1478
  });
1274
- const SYSTEM_TOOL_NAMES = new Set(["wait", "wait_on_worker", "cron", "cron_at", "ask_user", "report_cycle", "list_available_models", "update_session_summary", "send_session_message", "reply_session_message", "spawn_agent", "message_agent", "check_agents", "wait_for_agents", "list_sessions", "complete_agent", "cancel_agent", "delete_agent"]);
1479
+ const SYSTEM_TOOL_NAMES = new Set(["wait", "wait_on_worker", "cron", "cron_at", "ask_user", "report_cycle", "list_available_models", "set_session_model", "update_session_summary", "send_session_message", "reply_session_message", "spawn_agent", "message_agent", "check_agents", "wait_for_agents", "list_sessions", "complete_agent", "cancel_agent", "delete_agent"]);
1275
1480
  // Merge user tools with system tools
1276
1481
  const userTools = this.config.tools ?? [];
1277
1482
  // Wrap user tool handlers to augment invocation with the PilotSwarm
@@ -1310,6 +1515,7 @@ export class ManagedSession {
1310
1515
  askUserTool,
1311
1516
  reportCycleTool,
1312
1517
  listModelsTool,
1518
+ setSessionModelTool,
1313
1519
  updateSessionSummaryTool,
1314
1520
  sendSessionMessageTool,
1315
1521
  replySessionMessageTool,
@@ -1342,6 +1548,7 @@ export class ManagedSession {
1342
1548
  let lastPublishedReasoning = "";
1343
1549
  let lastReasoningPublishAt = 0;
1344
1550
  let deferredSessionError = null;
1551
+ const textEmittedToolCallRef = { current: null };
1345
1552
  // Streaming progress + turn timing state.
1346
1553
  // Token-level deltas (`assistant.message_delta`,
1347
1554
  // `assistant.streaming_delta`, `assistant.reasoning_delta`) stay
@@ -1435,6 +1642,9 @@ export class ManagedSession {
1435
1642
  }
1436
1643
  }
1437
1644
  const turnComplete = new Promise((resolve, reject) => {
1645
+ // Hang-escalation hook: forceSettleTurn() resolves this promise when
1646
+ // the SDK never fires session.idle (see stop-turn plan, edge E3).
1647
+ this.settleTurnResolver = resolve;
1438
1648
  // Catch-all event handler — captures every event and fires onEvent immediately.
1439
1649
  unsubscribers.push(this.copilotSession.on((event) => {
1440
1650
  const eventType = event.type ?? event.eventType ?? "unknown";
@@ -1480,6 +1690,16 @@ export class ManagedSession {
1480
1690
  if (isEmptyAssistantTranscriptEvent(eventType, eventData)) {
1481
1691
  return;
1482
1692
  }
1693
+ if (eventType === "assistant.message") {
1694
+ const content = extractAssistantMessageContent({ data: eventData });
1695
+ const textToolCall = detectTextEmittedToolCall(content);
1696
+ if (textToolCall) {
1697
+ textEmittedToolCallRef.current = textToolCall;
1698
+ return;
1699
+ }
1700
+ finalContent = content ?? finalContent;
1701
+ publishReasoningSnapshot("assistant.message", true);
1702
+ }
1483
1703
  // Track turn boundaries so we can stamp turn_end with a
1484
1704
  // durationMs and the streaming counters.
1485
1705
  if (eventType === "assistant.turn_start") {
@@ -1547,11 +1767,6 @@ export class ManagedSession {
1547
1767
  catch { }
1548
1768
  }
1549
1769
  }));
1550
- // Capture the final assistant message
1551
- unsubscribers.push(this.copilotSession.on("assistant.message", (event) => {
1552
- finalContent = extractAssistantMessageContent(event) ?? finalContent;
1553
- publishReasoningSnapshot("assistant.message", true);
1554
- }));
1555
1770
  unsubscribers.push(this.copilotSession.on("assistant.reasoning", (event) => {
1556
1771
  currentReasoning = String(extractReasoningText(event?.data ?? event) || "").trim();
1557
1772
  if (currentReasoning) {
@@ -1594,6 +1809,18 @@ export class ManagedSession {
1594
1809
  setTimeout(() => reject(new Error("Turn timed out")), TURN_TIMEOUT);
1595
1810
  })
1596
1811
  : null;
1812
+ // Re-armable idle waiter used by the tool-call-as-text guard to wait for
1813
+ // the model's response to a mid-turn correction without tearing down the
1814
+ // event subscriptions set up above.
1815
+ const waitForNextIdle = () => new Promise((resolve) => {
1816
+ const unsub = this.copilotSession.on("session.idle", () => {
1817
+ flushStreamingProgress(true);
1818
+ publishReasoningSnapshot("session.idle", true);
1819
+ unsub();
1820
+ resolve();
1821
+ });
1822
+ unsubscribers.push(unsub);
1823
+ });
1597
1824
  const effectivePrompt = opts?.requiredTool
1598
1825
  ? [
1599
1826
  `[SYSTEM: For this request, you MUST invoke the tool "${opts.requiredTool}" before giving your answer.`,
@@ -1618,6 +1845,61 @@ export class ManagedSession {
1618
1845
  else {
1619
1846
  await turnComplete;
1620
1847
  }
1848
+ // ── Guard: tool call emitted as text instead of executed ──────────
1849
+ // If the model typed a tool call as `<invoke .../>` text rather than
1850
+ // calling it, that call did not run. Re-prompt it (bounded) to
1851
+ // actually invoke the tool so a consequential call is never silently
1852
+ // dropped. If a control tool already scheduled a turn boundary, we
1853
+ // still return an error rather than accepting a transcript that
1854
+ // implied another unexecuted tool side effect.
1855
+ let textToolCallCorrections = 0;
1856
+ while (textToolCallCorrections < MAX_TEXT_TOOL_CALL_CORRECTIONS &&
1857
+ !hasTerminalTurnBoundary(turnState) &&
1858
+ textEmittedToolCallRef.current) {
1859
+ const offendingTool = textEmittedToolCallRef.current.toolName;
1860
+ const rawContent = textEmittedToolCallRef.current.rawContent;
1861
+ textEmittedToolCallRef.current = null;
1862
+ textToolCallCorrections++;
1863
+ const diagnostic = {
1864
+ eventType: "runtime.tool_call_as_text",
1865
+ data: { toolName: offendingTool, rawContent, attempt: textToolCallCorrections, sessionId: this.sessionId },
1866
+ };
1867
+ collectedEvents.push(diagnostic);
1868
+ if (opts?.onEvent) {
1869
+ try {
1870
+ opts.onEvent(diagnostic);
1871
+ }
1872
+ catch { }
1873
+ }
1874
+ finalContent = undefined;
1875
+ const nextIdle = waitForNextIdle();
1876
+ await this.copilotSession.send({ prompt: buildTextEmittedToolCallCorrection(offendingTool) });
1877
+ if (timeoutPromise) {
1878
+ await Promise.race([nextIdle, timeoutPromise]);
1879
+ }
1880
+ else {
1881
+ await nextIdle;
1882
+ }
1883
+ }
1884
+ if (textEmittedToolCallRef.current) {
1885
+ const diagnostic = {
1886
+ eventType: "runtime.tool_call_as_text",
1887
+ data: { toolName: textEmittedToolCallRef.current.toolName, rawContent: textEmittedToolCallRef.current.rawContent, final: true, sessionId: this.sessionId },
1888
+ };
1889
+ collectedEvents.push(diagnostic);
1890
+ if (opts?.onEvent) {
1891
+ try {
1892
+ opts.onEvent(diagnostic);
1893
+ }
1894
+ catch { }
1895
+ }
1896
+ const message = buildTextEmittedToolCallCorrection(textEmittedToolCallRef.current.toolName);
1897
+ return {
1898
+ type: "error",
1899
+ message,
1900
+ events: collectedEvents,
1901
+ };
1902
+ }
1621
1903
  }
1622
1904
  catch (err) {
1623
1905
  // Timeout — kill it
@@ -1648,6 +1930,8 @@ export class ManagedSession {
1648
1930
  const combinedQueuedActions = [...turnState.queuedActions, ...remainingActions];
1649
1931
  const queuedActions = combinedQueuedActions.length > 0 ? combinedQueuedActions : undefined;
1650
1932
  switch (firstAction.type) {
1933
+ case "completed":
1934
+ return { ...firstAction, events: collectedEvents, queuedActions };
1651
1935
  case "input_required":
1652
1936
  return { ...firstAction, events: collectedEvents, queuedActions };
1653
1937
  case "wait":
@@ -1723,6 +2007,8 @@ export class ManagedSession {
1723
2007
  updateConfig(config) {
1724
2008
  if (config.model !== undefined)
1725
2009
  this.config.model = config.model;
2010
+ if (config.reasoningEffort !== undefined)
2011
+ this.config.reasoningEffort = config.reasoningEffort;
1726
2012
  if (config.tools !== undefined)
1727
2013
  this.config.tools = config.tools;
1728
2014
  if (config.systemMessage !== undefined)
@@ -1732,6 +2018,16 @@ export class ManagedSession {
1732
2018
  if (config.waitThreshold !== undefined)
1733
2019
  this.config.waitThreshold = config.waitThreshold;
1734
2020
  }
2021
+ requiresModelRebind(config) {
2022
+ const currentModel = this.config.model;
2023
+ const nextModel = config.model ?? this.config.model;
2024
+ const currentReasoningEffort = this.config.reasoningEffort ?? null;
2025
+ const nextReasoningEffort = config.reasoningEffort !== undefined
2026
+ ? config.reasoningEffort ?? null
2027
+ : this.config.reasoningEffort ?? null;
2028
+ return Boolean((currentModel || nextModel)
2029
+ && (currentModel !== nextModel || currentReasoningEffort !== nextReasoningEffort));
2030
+ }
1735
2031
  /** Get the underlying CopilotSession (for direct access when needed). */
1736
2032
  getCopilotSession() {
1737
2033
  return this.copilotSession;