@tyvm/knowhow 0.0.137 → 0.0.140

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 (173) hide show
  1. package/autodoc/chat-guide.md +1 -0
  2. package/package.json +5 -2
  3. package/scripts/build-for-node.sh +4 -4
  4. package/src/agents/base/base.ts +254 -29
  5. package/src/agents/patcher/patcher.ts +1 -1
  6. package/src/agents/researcher/researcher.ts +2 -2
  7. package/src/agents/tools/agentCall.ts +6 -0
  8. package/src/agents/tools/connectAgent.ts +353 -0
  9. package/src/agents/tools/index.ts +6 -0
  10. package/src/agents/tools/list.ts +48 -0
  11. package/src/agents/tools/observe.ts +245 -0
  12. package/src/agents/tools/replyToParent.ts +122 -0
  13. package/src/agents/tools/runGenerate.ts +209 -0
  14. package/src/agents/tools/sendAgentMessage.ts +138 -0
  15. package/src/agents/tools/startAgentTask.ts +92 -5
  16. package/src/agents/tools/waitForAgentCompleted.ts +269 -0
  17. package/src/ai.ts +1 -0
  18. package/src/chat/modules/AgentModule.ts +89 -27
  19. package/src/chat/modules/SessionsModule.ts +6 -10
  20. package/src/clients/contextLimits.ts +26 -0
  21. package/src/clients/pricing/anthropic.ts +12 -0
  22. package/src/commands/agent.ts +22 -0
  23. package/src/commands/agents.ts +83 -0
  24. package/src/commands/hash.ts +3 -2
  25. package/src/commands/misc.ts +6 -0
  26. package/src/commands/services.ts +14 -0
  27. package/src/generate.ts +25 -3
  28. package/src/services/AgentService.ts +21 -3
  29. package/src/services/AgentSyncFs.ts +56 -0
  30. package/src/services/AgentSyncKnowhowWeb.ts +1 -1
  31. package/src/services/EventService.ts +4 -1
  32. package/src/services/KnowhowClient.ts +7 -0
  33. package/src/services/LazyToolsService.ts +7 -3
  34. package/src/services/MinimalToolsService.ts +15 -7
  35. package/src/services/SyncedAgentWatcher.ts +2 -0
  36. package/src/services/SyncerService.ts +35 -6
  37. package/src/services/Tools.ts +43 -3
  38. package/src/services/modules/index.ts +127 -46
  39. package/src/types.ts +5 -0
  40. package/tests/agents/interrupt.test.ts +11 -18
  41. package/tests/agents/observe-base.test.ts +183 -0
  42. package/tests/agents/tools/connectAgent.test.ts +138 -0
  43. package/tests/agents/tools/observe.test.ts +179 -0
  44. package/tests/agents/tools/replyToParent.test.ts +125 -0
  45. package/tests/agents/tools/sendAgentMessage.test.ts +99 -0
  46. package/tests/agents/tools/waitForAgentCompleted.test.ts +147 -0
  47. package/tests/chat/detach-attach-rendering.test.ts +315 -0
  48. package/tests/clients/contextLimits.test.ts +23 -1
  49. package/tests/clients/pricing.test.ts +20 -1
  50. package/tests/services/Tools.test.ts +172 -9
  51. package/tests/unit/modules/moduleLoading.test.ts +69 -0
  52. package/ts_build/package.json +5 -2
  53. package/ts_build/src/agents/base/base.d.ts +24 -1
  54. package/ts_build/src/agents/base/base.js +111 -16
  55. package/ts_build/src/agents/base/base.js.map +1 -1
  56. package/ts_build/src/agents/patcher/patcher.js +1 -1
  57. package/ts_build/src/agents/patcher/patcher.js.map +1 -1
  58. package/ts_build/src/agents/researcher/researcher.js +2 -2
  59. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  60. package/ts_build/src/agents/tools/agentCall.js.map +1 -1
  61. package/ts_build/src/agents/tools/connectAgent.d.ts +21 -0
  62. package/ts_build/src/agents/tools/connectAgent.js +284 -0
  63. package/ts_build/src/agents/tools/connectAgent.js.map +1 -0
  64. package/ts_build/src/agents/tools/index.d.ts +6 -0
  65. package/ts_build/src/agents/tools/index.js +6 -0
  66. package/ts_build/src/agents/tools/index.js.map +1 -1
  67. package/ts_build/src/agents/tools/list.js +40 -0
  68. package/ts_build/src/agents/tools/list.js.map +1 -1
  69. package/ts_build/src/agents/tools/observe.d.ts +20 -0
  70. package/ts_build/src/agents/tools/observe.js +144 -0
  71. package/ts_build/src/agents/tools/observe.js.map +1 -0
  72. package/ts_build/src/agents/tools/replyToParent.d.ts +4 -0
  73. package/ts_build/src/agents/tools/replyToParent.js +118 -0
  74. package/ts_build/src/agents/tools/replyToParent.js.map +1 -0
  75. package/ts_build/src/agents/tools/runGenerate.d.ts +19 -0
  76. package/ts_build/src/agents/tools/runGenerate.js +138 -0
  77. package/ts_build/src/agents/tools/runGenerate.js.map +1 -0
  78. package/ts_build/src/agents/tools/sendAgentMessage.d.ts +11 -0
  79. package/ts_build/src/agents/tools/sendAgentMessage.js +118 -0
  80. package/ts_build/src/agents/tools/sendAgentMessage.js.map +1 -0
  81. package/ts_build/src/agents/tools/startAgentTask.d.ts +7 -0
  82. package/ts_build/src/agents/tools/startAgentTask.js +53 -6
  83. package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
  84. package/ts_build/src/agents/tools/waitForAgentCompleted.d.ts +10 -0
  85. package/ts_build/src/agents/tools/waitForAgentCompleted.js +229 -0
  86. package/ts_build/src/agents/tools/waitForAgentCompleted.js.map +1 -0
  87. package/ts_build/src/ai.d.ts +1 -0
  88. package/ts_build/src/ai.js.map +1 -1
  89. package/ts_build/src/chat/modules/AgentModule.d.ts +3 -1
  90. package/ts_build/src/chat/modules/AgentModule.js +46 -21
  91. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  92. package/ts_build/src/chat/modules/SessionsModule.js +1 -6
  93. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  94. package/ts_build/src/clients/contextLimits.js +21 -0
  95. package/ts_build/src/clients/contextLimits.js.map +1 -1
  96. package/ts_build/src/clients/pricing/anthropic.d.ts +2 -0
  97. package/ts_build/src/clients/pricing/anthropic.js +9 -0
  98. package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
  99. package/ts_build/src/commands/agent.js +11 -0
  100. package/ts_build/src/commands/agent.js.map +1 -1
  101. package/ts_build/src/commands/agents.js +61 -0
  102. package/ts_build/src/commands/agents.js.map +1 -1
  103. package/ts_build/src/commands/hash.js +1 -1
  104. package/ts_build/src/commands/hash.js.map +1 -1
  105. package/ts_build/src/commands/misc.js +2 -0
  106. package/ts_build/src/commands/misc.js.map +1 -1
  107. package/ts_build/src/commands/services.js +10 -0
  108. package/ts_build/src/commands/services.js.map +1 -1
  109. package/ts_build/src/generate.d.ts +2 -0
  110. package/ts_build/src/generate.js +11 -3
  111. package/ts_build/src/generate.js.map +1 -1
  112. package/ts_build/src/services/AgentService.d.ts +4 -1
  113. package/ts_build/src/services/AgentService.js +9 -2
  114. package/ts_build/src/services/AgentService.js.map +1 -1
  115. package/ts_build/src/services/AgentSyncFs.d.ts +2 -0
  116. package/ts_build/src/services/AgentSyncFs.js +35 -0
  117. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  118. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -1
  119. package/ts_build/src/services/EventService.d.ts +4 -1
  120. package/ts_build/src/services/EventService.js.map +1 -1
  121. package/ts_build/src/services/KnowhowClient.js +3 -0
  122. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  123. package/ts_build/src/services/LazyToolsService.d.ts +2 -2
  124. package/ts_build/src/services/LazyToolsService.js +2 -2
  125. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  126. package/ts_build/src/services/MinimalToolsService.d.ts +2 -2
  127. package/ts_build/src/services/MinimalToolsService.js +6 -6
  128. package/ts_build/src/services/MinimalToolsService.js.map +1 -1
  129. package/ts_build/src/services/SyncedAgentWatcher.d.ts +1 -0
  130. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  131. package/ts_build/src/services/SyncerService.d.ts +2 -1
  132. package/ts_build/src/services/SyncerService.js +8 -2
  133. package/ts_build/src/services/SyncerService.js.map +1 -1
  134. package/ts_build/src/services/Tools.d.ts +6 -1
  135. package/ts_build/src/services/Tools.js +9 -3
  136. package/ts_build/src/services/Tools.js.map +1 -1
  137. package/ts_build/src/services/modules/index.d.ts +1 -1
  138. package/ts_build/src/services/modules/index.js +67 -27
  139. package/ts_build/src/services/modules/index.js.map +1 -1
  140. package/ts_build/src/types.d.ts +3 -0
  141. package/ts_build/src/types.js.map +1 -1
  142. package/ts_build/tests/agents/interrupt.test.d.ts +0 -1
  143. package/ts_build/tests/agents/interrupt.test.js +3 -13
  144. package/ts_build/tests/agents/interrupt.test.js.map +1 -1
  145. package/ts_build/tests/agents/observe-base.test.d.ts +1 -0
  146. package/ts_build/tests/agents/observe-base.test.js +149 -0
  147. package/ts_build/tests/agents/observe-base.test.js.map +1 -0
  148. package/ts_build/tests/agents/tools/connectAgent.test.d.ts +1 -0
  149. package/ts_build/tests/agents/tools/connectAgent.test.js +142 -0
  150. package/ts_build/tests/agents/tools/connectAgent.test.js.map +1 -0
  151. package/ts_build/tests/agents/tools/observe.test.d.ts +1 -0
  152. package/ts_build/tests/agents/tools/observe.test.js +148 -0
  153. package/ts_build/tests/agents/tools/observe.test.js.map +1 -0
  154. package/ts_build/tests/agents/tools/replyToParent.test.d.ts +1 -0
  155. package/ts_build/tests/agents/tools/replyToParent.test.js +116 -0
  156. package/ts_build/tests/agents/tools/replyToParent.test.js.map +1 -0
  157. package/ts_build/tests/agents/tools/sendAgentMessage.test.d.ts +1 -0
  158. package/ts_build/tests/agents/tools/sendAgentMessage.test.js +112 -0
  159. package/ts_build/tests/agents/tools/sendAgentMessage.test.js.map +1 -0
  160. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.d.ts +1 -0
  161. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js +147 -0
  162. package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js.map +1 -0
  163. package/ts_build/tests/chat/detach-attach-rendering.test.d.ts +1 -0
  164. package/ts_build/tests/chat/detach-attach-rendering.test.js +168 -0
  165. package/ts_build/tests/chat/detach-attach-rendering.test.js.map +1 -0
  166. package/ts_build/tests/clients/contextLimits.test.js +11 -1
  167. package/ts_build/tests/clients/contextLimits.test.js.map +1 -1
  168. package/ts_build/tests/clients/pricing.test.js +10 -1
  169. package/ts_build/tests/clients/pricing.test.js.map +1 -1
  170. package/ts_build/tests/services/Tools.test.js +126 -8
  171. package/ts_build/tests/services/Tools.test.js.map +1 -1
  172. package/ts_build/tests/unit/modules/moduleLoading.test.js +84 -0
  173. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
@@ -79,6 +79,7 @@ The following commands are available from the CLI modules shown in the code. (Ad
79
79
  | `/pause` | Pause the attached agent | `agent:attached` |
80
80
  | `/unpause` | Unpause the attached agent | `agent:attached` |
81
81
  | `/kill` | Terminate the attached agent | `agent:attached` |
82
+ | `/compact` | Force the attached agent to summarize/compact its conversation now, dropping back to a much cheaper context size | `agent:attached` |
82
83
  | `/detach` | Detach from the attached agent task (leaves agent running) | `agent:attached` |
83
84
  | `/done` | Exit the current agent interaction (detaches) | `agent:attached` |
84
85
 
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.137",
3
+ "version": "0.0.140",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
7
7
  "knowhow": "bin/knowhow.js"
8
8
  },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
9
12
  "scripts": {
10
13
  "test": "jest --testTimeout 300000",
11
14
  "test:debug": "node --inspect-brk ../../node_modules/jest/bin/jest.js --detectOpenHandles --forceExit --testTimeout 300000",
@@ -39,7 +42,7 @@
39
42
  "@types/express": "^4.17.13",
40
43
  "@types/jest": "^29.5.13",
41
44
  "@types/mocha": "^10.0.8",
42
- "@types/node": "^20.6.3",
45
+ "@types/node": "^24.0.0",
43
46
  "@types/ws": "^8.18.1",
44
47
  "jest": "^29.1.1",
45
48
  "prettier": "2.6.2",
@@ -5,7 +5,7 @@
5
5
  # Example: npm run node:build 24
6
6
  #
7
7
  # This script:
8
- # 1. Compiles TypeScript with Node 20 (required for workspace deps)
8
+ # 1. Compiles TypeScript with Node 24 (falls back to current node if unavailable)
9
9
  # 2. Creates /tmp/knowhow-node-<major> with the compiled output
10
10
  # 3. Symlinks the package globally for ALL installed nvm versions matching the target
11
11
  #
@@ -31,9 +31,9 @@ PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
31
31
 
32
32
  echo "📦 Package dir: $PACKAGE_DIR"
33
33
 
34
- # --- Find Node 20 for compiling TypeScript ---
34
+ # --- Find Node 24 for compiling TypeScript ---
35
35
  NODE20_BIN=""
36
- for dir in "$HOME/.nvm/versions/node"/v20.*/bin; do
36
+ for dir in "$HOME/.nvm/versions/node"/v24.*/bin; do
37
37
  if [ -f "$dir/node" ]; then
38
38
  NODE20_BIN="$dir/node"
39
39
  break
@@ -41,7 +41,7 @@ for dir in "$HOME/.nvm/versions/node"/v20.*/bin; do
41
41
  done
42
42
 
43
43
  if [ -z "$NODE20_BIN" ]; then
44
- echo "⚠️ Node 20 not found via nvm, falling back to current node for TS compile"
44
+ echo "⚠️ Node 24 not found via nvm, falling back to current node for TS compile"
45
45
  NODE20_BIN="$(which node)"
46
46
  fi
47
47
 
@@ -43,6 +43,41 @@ export interface ToolCallEvent {
43
43
  functionResp: any;
44
44
  }
45
45
 
46
+ /**
47
+ * A source is anything that can (a) start producing data, (b) hand each datum to
48
+ * a callback, and (c) be torn down. This is the unified contract behind
49
+ * `agent.observe()` — tool polls, events, streams, promises, peer agent threads,
50
+ * file tails, etc. all become adapters that satisfy this shape.
51
+ */
52
+ export interface ObservationSource {
53
+ /** Human-friendly label for logs / delivered messages. */
54
+ label: string;
55
+ /**
56
+ * Begin producing. Call `emit(datum)` for each piece of data. Return a
57
+ * teardown function (or void). May be sync or async.
58
+ */
59
+ start(
60
+ emit: (datum: unknown) => void
61
+ ): void | (() => void) | Promise<void | (() => void)>;
62
+ }
63
+
64
+ export interface ObserveOpts {
65
+ /** Only deliver when the datum changed since the last emit. Default true. */
66
+ onlyOnChange?: boolean;
67
+ /** Max number of updates before auto-stopping. Default 20. */
68
+ maxUpdates?: number;
69
+ /** Max total duration in ms before auto-stopping. Default 10 min. */
70
+ maxDurationMs?: number;
71
+ }
72
+
73
+ interface ActiveObservation {
74
+ id: string;
75
+ label: string;
76
+ updates: number;
77
+ teardownPromise: Promise<void | (() => void)>;
78
+ expiry?: NodeJS.Timeout;
79
+ }
80
+
46
81
  export abstract class BaseAgent implements IAgent {
47
82
  abstract name: string;
48
83
  abstract description: string;
@@ -63,25 +98,35 @@ export abstract class BaseAgent implements IAgent {
63
98
  protected turnCount = 0;
64
99
  protected totalCostUsd = 0;
65
100
  protected currentThread = 0;
66
- protected reasoningEffort: CompletionOptions["reasoning_effort"] | undefined = undefined;
101
+ protected reasoningEffort: CompletionOptions["reasoning_effort"] | undefined =
102
+ undefined;
67
103
  protected summarizeReasoning: boolean | undefined = undefined;
68
104
  protected totalInputTokens = 0;
69
105
  protected totalOutputTokens = 0;
70
106
  protected totalCacheReadTokens = 0;
71
107
  protected totalCacheWriteTokens = 0;
72
108
 
109
+ // The real prompt/input token count reported by the most recent completion
110
+ // response (input + cache read + cache write). This reflects exactly how many
111
+ // tokens the model consumed for the last request and is far more accurate than
112
+ // the whitespace-based estimate in getMessagesLength(). Used to decide when to
113
+ // compact. 0 means "no real usage seen yet" (fall back to the estimate).
114
+ protected lastPromptTokens = 0;
115
+
73
116
  protected compressThreshold = 30000;
74
117
  protected compressMinMessages = 30;
75
118
 
119
+ // Set when the user manually requests compaction (e.g. via /compact while
120
+ // attached). Forces the next loop iteration to compress the conversation
121
+ // regardless of whether the token threshold has been reached.
122
+ protected _forceCompact = false;
123
+
76
124
  // Interrupt support: resolves the currently awaited tool call or completion
77
125
  private _interruptResolve: (() => void) | null = null;
78
126
  // A monotonically-increasing token identifying the active interruptible window.
79
127
  // Used to ensure a stale (slow) operation completing in the background cannot
80
128
  // clobber a newer interruptible window's resolver.
81
129
  private _interruptToken = 0;
82
- // Set when interrupt() is called with no active window, so the next
83
- // makeInterruptible fires immediately instead of dropping the interrupt.
84
- private _pendingInterrupt = false;
85
130
 
86
131
  protected threads = [] as Message[][];
87
132
 
@@ -91,6 +136,12 @@ export abstract class BaseAgent implements IAgent {
91
136
  // Internal messages
92
137
  protected pendingMessages = [] as Message[];
93
138
 
139
+ // Active observations (subscriptions) owned by this agent instance. Kept
140
+ // per-agent (not module scope) so stopObserving() on one agent can't clobber
141
+ // another agent's observers, and so newTask() can tear them all down.
142
+ protected _observations = new Map<string, ActiveObservation>();
143
+ private _observationSeq = 0;
144
+
94
145
  protected taskBreakdown = "";
95
146
  protected summaries = [] as string[];
96
147
  protected currentTaskId: string | null = null;
@@ -178,11 +229,24 @@ export abstract class BaseAgent implements IAgent {
178
229
  this.compressThreshold = threshold;
179
230
  }
180
231
 
232
+ /**
233
+ * Request that the conversation be compacted at the next loop iteration,
234
+ * regardless of whether the token threshold has been reached. Combined with
235
+ * an interrupt(), this lets a user force compaction on an attached agent
236
+ * mid-task (e.g. via the `/compact` chat command) to drop back to a much
237
+ * cheaper per-interaction context size.
238
+ */
239
+ requestCompact() {
240
+ this._forceCompact = true;
241
+ }
242
+
181
243
  /**
182
244
  * Returns the effective compress threshold for the current model.
183
245
  * If the user has manually set a custom threshold (different from the default 30k),
184
246
  * that value is used as-is. Otherwise, the threshold is dynamically computed as
185
- * 85% of the model's context window limit, falling back to DEFAULT_CONTEXT_LIMIT.
247
+ * 70% of the model's context window limit (or tiered-pricing threshold), falling
248
+ * back to DEFAULT_CONTEXT_LIMIT. The 70% factor leaves headroom so token-count
249
+ * lag doesn't push a request past the model/tier limit before compression fires.
186
250
  */
187
251
  getCompressThreshold(): number {
188
252
  if (this.compressThreshold !== DEFAULT_CONTEXT_LIMIT) {
@@ -194,7 +258,7 @@ export abstract class BaseAgent implements IAgent {
194
258
  );
195
259
  const contextLimit = result?.contextLimit ?? DEFAULT_CONTEXT_LIMIT;
196
260
  const threshold = result?.threshold ?? contextLimit;
197
- return Math.floor(threshold * 0.85);
261
+ return Math.floor(threshold * 0.7);
198
262
  }
199
263
 
200
264
  setMaxTurns(maxTurns: number | null) {
@@ -219,6 +283,7 @@ export abstract class BaseAgent implements IAgent {
219
283
  this.totalOutputTokens = 0;
220
284
  this.totalCacheReadTokens = 0;
221
285
  this.totalCacheWriteTokens = 0;
286
+ this.lastPromptTokens = 0;
222
287
  this.status = this.eventTypes.inProgress;
223
288
  this.turnCount = 0;
224
289
  this.startTimeMs = Date.now();
@@ -227,7 +292,11 @@ export abstract class BaseAgent implements IAgent {
227
292
  // Reset interrupt state so a queued/stale interrupt from a prior task
228
293
  // can't fire against the new task.
229
294
  this._interruptResolve = null;
230
- this._pendingInterrupt = false;
295
+ this._forceCompact = false;
296
+
297
+ // Tear down any observers from a prior task so they can't leak or inject
298
+ // into the new task.
299
+ this.stopObserving(undefined, "new task started");
231
300
 
232
301
  // Emit event for plugin integration
233
302
  const id = taskId || this.startTimeMs.toString();
@@ -473,6 +542,18 @@ export abstract class BaseAgent implements IAgent {
473
542
  const cacheWriteTokens =
474
543
  usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0;
475
544
 
545
+ // Record the real prompt token count for this single completion (not the
546
+ // running total). This represents the full context the model processed for
547
+ // the request: fresh input + cache-read + cache-write tokens. It is used by
548
+ // the compaction check as an accurate replacement for the whitespace-based
549
+ // getMessagesLength() estimate. Only track it for "prompt-shaped" usage
550
+ // (i.e. when there are input tokens) so a usage-less interrupt stub doesn't
551
+ // reset it to 0.
552
+ const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
553
+ if (promptTokens > 0) {
554
+ this.lastPromptTokens = promptTokens;
555
+ }
556
+
476
557
  this.totalInputTokens += inputTokens;
477
558
  this.totalOutputTokens += outputTokens;
478
559
  this.totalCacheReadTokens += cacheReadTokens;
@@ -575,7 +656,14 @@ export abstract class BaseAgent implements IAgent {
575
656
  };
576
657
 
577
658
  const { functionResp, toolMessages } = await this.makeInterruptible(
578
- this.tools.callTool(toolCall, this.getEnabledToolNames()),
659
+ // Pass this agent in as the per-call context (`_ctx`) so tools can be
660
+ // self-referential: they can read `_ctx.caller` (this agent) and
661
+ // `_ctx.taskId` to, e.g., look up the current task, send pending
662
+ // messages, or subscribe to the caller's events.
663
+ this.tools.callTool(toolCall, this.getEnabledToolNames(), {
664
+ caller: this,
665
+ taskId: this.currentTaskId,
666
+ }),
579
667
  interruptResult
580
668
  );
581
669
 
@@ -771,13 +859,6 @@ export abstract class BaseAgent implements IAgent {
771
859
 
772
860
  this._interruptResolve = () => finish(() => resolve(interruptValue));
773
861
 
774
- // If an interrupt arrived while there was no active window, honor it now.
775
- if (this._pendingInterrupt) {
776
- this._pendingInterrupt = false;
777
- finish(() => resolve(interruptValue));
778
- return;
779
- }
780
-
781
862
  promise
782
863
  .then((result) => {
783
864
  // If this window was already interrupted/settled, drop the stale
@@ -815,12 +896,13 @@ export abstract class BaseAgent implements IAgent {
815
896
  this._interruptResolve();
816
897
  } else {
817
898
  // No active interruptible window right now (we're between the AI
818
- // completion and the tool call, or in message processing). Queue the
819
- // interrupt so the next makeInterruptible window fires immediately
820
- // instead of silently dropping it.
821
- this._pendingInterrupt = true;
899
+ // completion and the tool call, or in message processing). There is
900
+ // nothing to interrupt, so we do NOT queue it — a queued interrupt
901
+ // would pre-empt the next tool call and drop its result. Any message
902
+ // supplied with the poke was already added as a pending user message
903
+ // above, so the agent will still see it on its next step.
822
904
  this.log(
823
- "No active interruptible operation — queued interrupt for next step",
905
+ "No active interruptible operation to interrupt",
824
906
  "warn"
825
907
  );
826
908
  }
@@ -917,8 +999,12 @@ export abstract class BaseAgent implements IAgent {
917
999
  tools: this.getEnabledTools(),
918
1000
  tool_choice: "auto",
919
1001
  long_ttl_cache: this.runTime() > 300_000,
920
- ...(this.reasoningEffort !== undefined && { reasoning_effort: this.reasoningEffort }),
921
- ...(this.summarizeReasoning !== undefined && { reasoning_summary: this.summarizeReasoning }),
1002
+ ...(this.reasoningEffort !== undefined && {
1003
+ reasoning_effort: this.reasoningEffort,
1004
+ }),
1005
+ ...(this.summarizeReasoning !== undefined && {
1006
+ reasoning_summary: this.summarizeReasoning,
1007
+ }),
922
1008
  },
923
1009
  { interruptValue: interruptResponse }
924
1010
  );
@@ -1109,18 +1195,37 @@ export abstract class BaseAgent implements IAgent {
1109
1195
  return firstMessage.content;
1110
1196
  }
1111
1197
 
1112
- if (
1113
- this.getMessagesLength(messages) > this.getCompressThreshold() &&
1114
- messages.length > this.compressMinMessages
1115
- ) {
1198
+ // Compress when either the token threshold is exceeded OR the user has
1199
+ // manually requested compaction (e.g. via /compact on an attached agent).
1200
+ // A manual request bypasses the token threshold but still respects the
1201
+ // minimum-message guard so we never try to compress a tiny conversation.
1202
+ //
1203
+ // The size check uses getContextTokenCount(), which prefers the REAL
1204
+ // prompt token count from the last completion response over the
1205
+ // whitespace-based estimate — so it compares real tokens against the
1206
+ // real (token-based) compress threshold.
1207
+ const contextTokens = this.getContextTokenCount(messages);
1208
+ const overThreshold =
1209
+ contextTokens > this.getCompressThreshold() &&
1210
+ messages.length > this.compressMinMessages;
1211
+
1212
+ if (overThreshold || this._forceCompact) {
1116
1213
  const taskBreakdown = await this.getTaskBreakdown(messages);
1117
1214
  this.log(
1118
- `Compressing messages: ${this.getMessagesLength(
1119
- messages
1120
- )} exceeds ${this.getCompressThreshold()}`
1215
+ this._forceCompact
1216
+ ? `Compacting messages (manual /compact request): ${contextTokens} tokens`
1217
+ : `Compressing messages: ${contextTokens} tokens exceeds ${this.getCompressThreshold()}`
1121
1218
  );
1219
+ this._forceCompact = false;
1122
1220
  messages = await this.compressMessages(messages, startIndex, endIndex);
1123
1221
  this.startNewThread(messages);
1222
+ // Reset the real-token tracker. compressMessages() ran a completion over
1223
+ // the FULL (pre-compression) thread, so lastPromptTokens now reflects
1224
+ // that large context — not the small compressed thread. Zeroing it makes
1225
+ // getContextTokenCount() fall back to the whitespace estimate of the new
1226
+ // (small) thread until the next real completion reports fresh usage,
1227
+ // preventing an immediate false re-compaction on the following iteration.
1228
+ this.lastPromptTokens = 0;
1124
1229
  }
1125
1230
 
1126
1231
  if (["assistant", "tool"].includes(messages[messages.length - 1].role)) {
@@ -1284,10 +1389,130 @@ export abstract class BaseAgent implements IAgent {
1284
1389
  this.events.emit(this.eventTypes.userSay, message.content);
1285
1390
  }
1286
1391
 
1392
+ /**
1393
+ * Subscribe to any data-yielding source and stream its data into this agent's
1394
+ * loop as non-blocking pending messages. This is the unified subscription
1395
+ * primitive behind the `observe` tool — a tool poll, an event, a stream, a
1396
+ * peer agent's thread, a file tail, etc. all become `ObservationSource`
1397
+ * adapters feeding the same sink (`addPendingMessage`).
1398
+ *
1399
+ * Returns an observation id that can be passed to `stopObserving`. Auto-expires
1400
+ * after `maxUpdates` or `maxDurationMs` so a forgotten observer never leaks.
1401
+ */
1402
+ observe(source: ObservationSource, opts: ObserveOpts = {}): string {
1403
+ const {
1404
+ onlyOnChange = true,
1405
+ maxUpdates = 20,
1406
+ maxDurationMs = 10 * 60 * 1000,
1407
+ } = opts;
1408
+
1409
+ this._observationSeq += 1;
1410
+ // Keep the id free of characters that would break id-extraction regexes
1411
+ // (labels can contain parens, spaces, JSON, etc).
1412
+ const safeLabel = source.label.replace(/[^A-Za-z0-9]+/g, "_").slice(0, 24);
1413
+ const id = `obs_${safeLabel}_${Date.now()}_${this._observationSeq}`;
1414
+ let last: string | undefined;
1415
+
1416
+ const emit = (datum: unknown) => {
1417
+ // status gate: don't inject once the agent is done
1418
+ if (this.status === this.eventTypes.done) {
1419
+ this.stopObserving(id);
1420
+ return;
1421
+ }
1422
+ const str = typeof datum === "string" ? datum : JSON.stringify(datum);
1423
+ if (onlyOnChange && str === last) return; // dedupe noise
1424
+ last = str;
1425
+ const obs = this._observations.get(id);
1426
+ if (!obs) return;
1427
+ obs.updates += 1;
1428
+ this.addPendingMessage({
1429
+ role: "user",
1430
+ content: `[observe:${id}] ${source.label} (update ${obs.updates}/${maxUpdates}):\n${str}`,
1431
+ });
1432
+ if (obs.updates >= maxUpdates) {
1433
+ this.stopObserving(id, "max updates reached");
1434
+ }
1435
+ };
1436
+
1437
+ const teardownPromise = Promise.resolve(source.start(emit)).catch((e) => {
1438
+ this.log(`observe(${source.label}) start failed: ${e?.message ?? e}`, "warn");
1439
+ return undefined;
1440
+ });
1441
+
1442
+ const expiry = maxDurationMs
1443
+ ? setTimeout(() => this.stopObserving(id, "max duration reached"), maxDurationMs)
1444
+ : undefined;
1445
+ if (expiry && typeof (expiry as any).unref === "function") {
1446
+ (expiry as any).unref();
1447
+ }
1448
+
1449
+ this._observations.set(id, {
1450
+ id,
1451
+ label: source.label,
1452
+ updates: 0,
1453
+ teardownPromise,
1454
+ expiry,
1455
+ });
1456
+ return id;
1457
+ }
1458
+
1459
+ /**
1460
+ * Cancel one observation (by id) or all of them (no id). Companion to
1461
+ * `observe`. Runs each source's teardown function if it returned one.
1462
+ */
1463
+ stopObserving(id?: string, reason = "stopped"): string {
1464
+ const ids = id ? [id] : [...this._observations.keys()];
1465
+ let stopped = 0;
1466
+ for (const oid of ids) {
1467
+ const obs = this._observations.get(oid);
1468
+ if (!obs) continue;
1469
+ if (obs.expiry) clearTimeout(obs.expiry);
1470
+ Promise.resolve(obs.teardownPromise)
1471
+ .then((fn) => {
1472
+ if (typeof fn === "function") fn();
1473
+ })
1474
+ .catch(() => {});
1475
+ this._observations.delete(oid);
1476
+ stopped += 1;
1477
+ }
1478
+ return id
1479
+ ? stopped
1480
+ ? `Stopped observer ${id} (${reason}).`
1481
+ : `No active observer found with id: ${id}.`
1482
+ : `Stopped ${stopped} active observer(s) (${reason}).`;
1483
+ }
1484
+
1287
1485
  getMessagesLength(messages: Message[]) {
1288
1486
  return JSON.stringify(messages).split(" ").length;
1289
1487
  }
1290
1488
 
1489
+ /**
1490
+ * Returns the best available estimate of how many tokens the current
1491
+ * conversation occupies in the model's context window, for the purpose of
1492
+ * deciding when to compact.
1493
+ *
1494
+ * Prefers the REAL prompt token count reported by the most recent completion
1495
+ * response (lastPromptTokens) — this is exactly how many tokens the model
1496
+ * consumed for the last request (fresh input + cache read + cache write), so
1497
+ * it is far more accurate than the whitespace-based getMessagesLength()
1498
+ * heuristic (which counts space-separated chunks of the JSON-stringified
1499
+ * messages and can be off by a large factor).
1500
+ *
1501
+ * Falls back to the whitespace estimate when no real usage has been observed
1502
+ * yet (e.g. before the first completion, or when a provider omits usage).
1503
+ *
1504
+ * Note: lastPromptTokens reflects the context size at the START of the last
1505
+ * request, so it lags by whatever the model generated + tool results appended
1506
+ * since then. That lag is intentionally covered by the 70% headroom baked
1507
+ * into getCompressThreshold().
1508
+ */
1509
+ getContextTokenCount(messages: Message[]): number {
1510
+ if (this.lastPromptTokens > 0) {
1511
+ return this.lastPromptTokens;
1512
+ }
1513
+ return this.getMessagesLength(messages);
1514
+ }
1515
+
1291
1516
  /**
1292
1517
  * Detects whether tool call arguments appear truncated due to hitting the output token limit.
1293
1518
  * Two signals are checked:
@@ -80,7 +80,7 @@ export class PatchingAgent extends BaseAgent {
80
80
 
81
81
  this.setModelPreferences([
82
82
  { model: Models.anthropic.Sonnet4_6, provider: "anthropic" },
83
- { model: Models.openai.GPT_53_Codex, provider: "openai" },
83
+ { model: Models.openai.GPT_56_Sol, provider: "openai" },
84
84
  { model: Models.xai.GrokCodeFast, provider: "xai" },
85
85
  ]);
86
86
  }
@@ -13,9 +13,9 @@ export class ResearcherAgent extends BaseAgent {
13
13
  // fallbacks, an unregistered "google" provider causes the health-check loop
14
14
  // to immediately throw "We have exhausted all model preferences."
15
15
  this.setModelPreferences([
16
- { model: Models.google.Gemini_3_Flash_Preview, provider: "google" },
16
+ { model: Models.openai.GPT_56_Terra, provider: "openai" },
17
17
  { model: Models.anthropic.Sonnet4_6, provider: "anthropic" },
18
- { model: Models.openai.GPT_53_Codex, provider: "openai" },
18
+ { model: Models.google.Gemini_35_Flash_Lite, provider: "google" },
19
19
  ]);
20
20
  this.disableTool("patchFile");
21
21
  this.disableTool("writeFile");
@@ -2,6 +2,12 @@ import { getConfig } from "../../config";
2
2
  import { services, ToolsService } from "../../services";
3
3
  import { getEnabledPlugins } from "../../types";
4
4
 
5
+ /**
6
+ * Ask another registered agent a question. Resolves with a structured
7
+ * `{ answer, costUsd }` object (via `AgentService.callAgent`) so callers — most
8
+ * importantly the script sandbox — can account the subagent's spend against a
9
+ * budget. `answer` is the target agent's final response.
10
+ */
5
11
  export async function agentCall(agentName: string, userInput: string) {
6
12
  return new Promise(async (resolve, reject) => {
7
13
  const config = await getConfig();