@roj-ai/sdk 0.1.12 → 0.1.14

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 (88) hide show
  1. package/dist/bootstrap.d.ts +18 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/config.d.ts +2 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +3 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  10. package/dist/core/sessions/session-manager.js +13 -5
  11. package/dist/core/sessions/session-manager.js.map +1 -1
  12. package/dist/lib/utils/concurrency.d.ts +25 -0
  13. package/dist/lib/utils/concurrency.d.ts.map +1 -0
  14. package/dist/lib/utils/concurrency.js +69 -0
  15. package/dist/lib/utils/concurrency.js.map +1 -0
  16. package/dist/lib/utils/concurrency.test.d.ts +2 -0
  17. package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
  18. package/dist/lib/utils/concurrency.test.js +135 -0
  19. package/dist/lib/utils/concurrency.test.js.map +1 -0
  20. package/dist/plugins/agents/plugin.d.ts +20 -0
  21. package/dist/plugins/agents/plugin.d.ts.map +1 -1
  22. package/dist/plugins/agents/plugin.js +189 -2
  23. package/dist/plugins/agents/plugin.js.map +1 -1
  24. package/dist/plugins/agents/supervision.integration.test.d.ts +2 -0
  25. package/dist/plugins/agents/supervision.integration.test.d.ts.map +1 -0
  26. package/dist/plugins/agents/supervision.integration.test.js +215 -0
  27. package/dist/plugins/agents/supervision.integration.test.js.map +1 -0
  28. package/dist/plugins/mailbox/plugin.d.ts +1 -0
  29. package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
  30. package/dist/plugins/mailbox/plugin.js +17 -0
  31. package/dist/plugins/mailbox/plugin.js.map +1 -1
  32. package/dist/plugins/mailbox/schema.d.ts +1 -1
  33. package/dist/plugins/mailbox/schema.d.ts.map +1 -1
  34. package/dist/plugins/mailbox/state.d.ts +2 -1
  35. package/dist/plugins/mailbox/state.d.ts.map +1 -1
  36. package/dist/plugins/mailbox/state.js +1 -1
  37. package/dist/plugins/mailbox/state.js.map +1 -1
  38. package/dist/plugins/uploads/plugin.d.ts +12 -0
  39. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  40. package/dist/plugins/uploads/plugin.js +188 -44
  41. package/dist/plugins/uploads/plugin.js.map +1 -1
  42. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
  43. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
  44. package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
  45. package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
  46. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
  47. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
  48. package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
  49. package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
  50. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
  51. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
  52. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
  53. package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
  54. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
  55. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
  56. package/dist/plugins/uploads/state.d.ts +1 -0
  57. package/dist/plugins/uploads/state.d.ts.map +1 -1
  58. package/dist/plugins/uploads/state.js +1 -1
  59. package/dist/plugins/uploads/state.js.map +1 -1
  60. package/dist/plugins/uploads/uploads.integration.test.js +97 -0
  61. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  62. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  63. package/dist/transport/http/routes/upload.d.ts.map +1 -1
  64. package/dist/transport/http/routes/upload.js +60 -0
  65. package/dist/transport/http/routes/upload.js.map +1 -1
  66. package/dist/user-config.d.ts +14 -0
  67. package/dist/user-config.d.ts.map +1 -1
  68. package/dist/user-config.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/bootstrap.ts +3 -1
  71. package/src/config.ts +6 -0
  72. package/src/core/sessions/session-manager.ts +14 -5
  73. package/src/lib/utils/concurrency.test.ts +169 -0
  74. package/src/lib/utils/concurrency.ts +72 -0
  75. package/src/plugins/agents/plugin.ts +228 -3
  76. package/src/plugins/agents/supervision.integration.test.ts +249 -0
  77. package/src/plugins/mailbox/plugin.ts +20 -0
  78. package/src/plugins/mailbox/schema.ts +1 -0
  79. package/src/plugins/mailbox/state.ts +2 -1
  80. package/src/plugins/uploads/plugin.ts +212 -47
  81. package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
  82. package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
  83. package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
  84. package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
  85. package/src/plugins/uploads/state.ts +1 -1
  86. package/src/plugins/uploads/uploads.integration.test.ts +123 -0
  87. package/src/transport/http/routes/upload.ts +87 -0
  88. package/src/user-config.ts +15 -0
@@ -1 +1 @@
1
- {"version":3,"file":"user-config.js","sourceRoot":"","sources":["../src/user-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAwCH;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC7C,OAAO,MAAM,CAAA;AACd,CAAC"}
1
+ {"version":3,"file":"user-config.js","sourceRoot":"","sources":["../src/user-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuDH;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC7C,OAAO,MAAM,CAAA;AACd,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roj-ai/sdk",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -135,7 +135,7 @@
135
135
  "type-check": "tsc --noEmit"
136
136
  },
137
137
  "dependencies": {
138
- "@roj-ai/transport": "^0.1.12",
138
+ "@roj-ai/transport": "^0.1.14",
139
139
  "@hono/zod-validator": "0.7.6",
140
140
  "hono": "4.12.5",
141
141
  "ignore": "7.0.5",
package/src/bootstrap.ts CHANGED
@@ -28,6 +28,7 @@ import type { ToolExecutor } from './core/tools/executor.js'
28
28
  import { ToolExecutor as ToolExecutorImpl } from './core/tools/executor.js'
29
29
  import { ConsoleLogger, JsonLogger } from './lib/logger/index.js'
30
30
  import type { Logger } from './lib/logger/logger.js'
31
+ import { Semaphore } from './lib/utils/concurrency.js'
31
32
  import { agentStatusPlugin } from './plugins/agent-status/plugin.js'
32
33
  import { agentsPlugin } from './plugins/agents/plugin.js'
33
34
  import { filesystemPlugin } from './plugins/filesystem/index.js'
@@ -119,7 +120,8 @@ export function bootstrap(config: Config, userConfig: RojConfig, platform: Platf
119
120
  const portPool = new PortPool()
120
121
 
121
122
  const preprocessorRegistry = new PreprocessorRegistry()
122
- preprocessorRegistry.register(new ImageClassifierPreprocessor({ llmProvider, logger, fs: platform.fs }))
123
+ const imageClassifierGate = new Semaphore(config.imageClassifierConcurrency ?? 10)
124
+ preprocessorRegistry.register(new ImageClassifierPreprocessor({ llmProvider, logger, fs: platform.fs, gate: imageClassifierGate }))
123
125
  preprocessorRegistry.register(new MarkitdownPreprocessor({ registry: preprocessorRegistry, logger, fs: platform.fs, process: platform.process }))
124
126
  preprocessorRegistry.register(new ZipPreprocessor({ registry: preprocessorRegistry, logger, process: platform.process }))
125
127
 
package/src/config.ts CHANGED
@@ -30,6 +30,9 @@ export interface Config {
30
30
  // LLM Logging
31
31
  llmLoggingEnabled?: boolean
32
32
 
33
+ /** Max concurrent vision LLM calls when classifying uploaded images. Default 10. */
34
+ imageClassifierConcurrency?: number
35
+
33
36
  // Logging
34
37
  logLevel: LogLevel
35
38
  logFormat: 'console' | 'json'
@@ -59,6 +62,9 @@ export const loadConfig = (): Config => {
59
62
  defaultModel: process.env.DEFAULT_MODEL ?? 'anthropic/claude-haiku-4.5',
60
63
  thinkingBudget: process.env.THINKING_BUDGET ? parseInt(process.env.THINKING_BUDGET, 10) : undefined,
61
64
  llmLoggingEnabled: process.env.LLM_LOGGING_ENABLED !== 'false',
65
+ imageClassifierConcurrency: process.env.IMAGE_CLASSIFIER_CONCURRENCY
66
+ ? parseInt(process.env.IMAGE_CLASSIFIER_CONCURRENCY, 10)
67
+ : undefined,
62
68
  logLevel: (process.env.LOG_LEVEL ?? 'info') as LogLevel,
63
69
  logFormat: (process.env.LOG_FORMAT ?? 'console') as 'console' | 'json',
64
70
  workerUrl: process.env.WORKER_URL,
@@ -677,15 +677,24 @@ export class SessionManager {
677
677
  const plugins: ConfiguredPlugin[] = []
678
678
 
679
679
  for (const pluginDef of this.systemPlugins) {
680
- // Determine config: preset explicit > infra auto-derived > no config (void)
680
+ // Determine config: merge infra (auto-derived) + preset explicit (overrides),
681
+ // fall back to whichever exists, else no config (void).
681
682
  let config: unknown
682
683
  let hasConfig = false
683
684
 
684
- if (presetConfigs.has(pluginDef.name)) {
685
- config = presetConfigs.get(pluginDef.name)
685
+ const presetConfig = presetConfigs.get(pluginDef.name)
686
+ const infraConfig = infraConfigs.get(pluginDef.name)
687
+ const isMergeable = (v: unknown): v is Record<string, unknown> =>
688
+ typeof v === 'object' && v !== null && !Array.isArray(v)
689
+
690
+ if (presetConfig !== undefined && infraConfig !== undefined && isMergeable(presetConfig) && isMergeable(infraConfig)) {
691
+ config = { ...infraConfig, ...presetConfig }
692
+ hasConfig = true
693
+ } else if (presetConfig !== undefined) {
694
+ config = presetConfig
686
695
  hasConfig = true
687
- } else if (infraConfigs.has(pluginDef.name)) {
688
- config = infraConfigs.get(pluginDef.name)
696
+ } else if (infraConfig !== undefined) {
697
+ config = infraConfig
689
698
  hasConfig = true
690
699
  }
691
700
 
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { mapWithConcurrency, Semaphore } from './concurrency.js'
3
+
4
+ function defer<T = void>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void } {
5
+ let resolve!: (v: T) => void
6
+ let reject!: (e: unknown) => void
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res
9
+ reject = rej
10
+ })
11
+ return { promise, resolve, reject }
12
+ }
13
+
14
+ describe('mapWithConcurrency', () => {
15
+ it('preserves input order regardless of completion order', async () => {
16
+ const delays = [50, 10, 30, 5, 20]
17
+ const results = await mapWithConcurrency(delays, 3, async (ms, i) => {
18
+ await new Promise(r => setTimeout(r, ms))
19
+ return `${i}:${ms}`
20
+ })
21
+ expect(results).toEqual(['0:50', '1:10', '2:30', '3:5', '4:20'])
22
+ })
23
+
24
+ it('does not exceed the concurrency limit', async () => {
25
+ let active = 0
26
+ let peak = 0
27
+ const items = Array.from({ length: 20 }, (_, i) => i)
28
+
29
+ await mapWithConcurrency(items, 4, async () => {
30
+ active++
31
+ peak = Math.max(peak, active)
32
+ await new Promise(r => setTimeout(r, 5))
33
+ active--
34
+ })
35
+
36
+ expect(peak).toBeLessThanOrEqual(4)
37
+ expect(peak).toBe(4)
38
+ })
39
+
40
+ it('handles empty input without spawning workers', async () => {
41
+ let calls = 0
42
+ const results = await mapWithConcurrency([], 5, async () => {
43
+ calls++
44
+ return 1
45
+ })
46
+ expect(results).toEqual([])
47
+ expect(calls).toBe(0)
48
+ })
49
+
50
+ it('caps worker count at item count when concurrency > items', async () => {
51
+ let active = 0
52
+ let peak = 0
53
+ const items = [1, 2]
54
+
55
+ await mapWithConcurrency(items, 10, async () => {
56
+ active++
57
+ peak = Math.max(peak, active)
58
+ await new Promise(r => setTimeout(r, 5))
59
+ active--
60
+ })
61
+
62
+ expect(peak).toBe(2)
63
+ })
64
+
65
+ it('propagates errors thrown by the worker fn', async () => {
66
+ await expect(
67
+ mapWithConcurrency([1, 2, 3], 2, async (n) => {
68
+ if (n === 2) throw new Error('boom')
69
+ return n
70
+ }),
71
+ ).rejects.toThrow('boom')
72
+ })
73
+ })
74
+
75
+ describe('Semaphore', () => {
76
+ it('rejects invalid limits', () => {
77
+ expect(() => new Semaphore(0)).toThrow()
78
+ expect(() => new Semaphore(-1)).toThrow()
79
+ expect(() => new Semaphore(1.5)).toThrow()
80
+ })
81
+
82
+ it('caps concurrent executions at limit', async () => {
83
+ const gate = new Semaphore(3)
84
+ let active = 0
85
+ let peak = 0
86
+
87
+ const tasks = Array.from({ length: 12 }, () =>
88
+ gate.run(async () => {
89
+ active++
90
+ peak = Math.max(peak, active)
91
+ await new Promise(r => setTimeout(r, 10))
92
+ active--
93
+ }),
94
+ )
95
+
96
+ await Promise.all(tasks)
97
+ expect(peak).toBe(3)
98
+ expect(active).toBe(0)
99
+ })
100
+
101
+ it('admits waiters in FIFO order', async () => {
102
+ const gate = new Semaphore(1)
103
+ const order: number[] = []
104
+ const blocker = defer()
105
+
106
+ // Hold the only slot
107
+ const held = gate.run(async () => {
108
+ await blocker.promise
109
+ })
110
+
111
+ // Queue three waiters in known order, with their own blockers so the test
112
+ // can observe entry order without relying on real timers.
113
+ const gates = [defer(), defer(), defer()]
114
+ const queued = gates.map((g, i) =>
115
+ gate.run(async () => {
116
+ order.push(i)
117
+ await g.promise
118
+ }),
119
+ )
120
+
121
+ // Yield so all three are queued.
122
+ await new Promise(r => setTimeout(r, 0))
123
+ expect(order).toEqual([])
124
+
125
+ // Release the holder; waiter 0 should run first.
126
+ blocker.resolve()
127
+ await new Promise(r => setTimeout(r, 0))
128
+ expect(order).toEqual([0])
129
+
130
+ gates[0]!.resolve()
131
+ await new Promise(r => setTimeout(r, 0))
132
+ expect(order).toEqual([0, 1])
133
+
134
+ gates[1]!.resolve()
135
+ await new Promise(r => setTimeout(r, 0))
136
+ expect(order).toEqual([0, 1, 2])
137
+
138
+ gates[2]!.resolve()
139
+ await Promise.all([held, ...queued])
140
+ })
141
+
142
+ it('releases the slot when the body throws', async () => {
143
+ const gate = new Semaphore(1)
144
+
145
+ await expect(gate.run(async () => {
146
+ throw new Error('boom')
147
+ })).rejects.toThrow('boom')
148
+
149
+ // Slot must be free again — this would deadlock otherwise.
150
+ const result = await gate.run(async () => 42)
151
+ expect(result).toBe(42)
152
+ })
153
+
154
+ it('serializes work under limit=1', async () => {
155
+ const gate = new Semaphore(1)
156
+ const events: string[] = []
157
+
158
+ const tasks = [0, 1, 2].map(i =>
159
+ gate.run(async () => {
160
+ events.push(`start:${i}`)
161
+ await new Promise(r => setTimeout(r, 5))
162
+ events.push(`end:${i}`)
163
+ }),
164
+ )
165
+
166
+ await Promise.all(tasks)
167
+ expect(events).toEqual(['start:0', 'end:0', 'start:1', 'end:1', 'start:2', 'end:2'])
168
+ })
169
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Run an async mapping with bounded concurrency.
3
+ *
4
+ * Spawns up to `concurrency` workers that pull from a shared cursor.
5
+ * Results preserve input order.
6
+ */
7
+ export async function mapWithConcurrency<T, R>(
8
+ items: readonly T[],
9
+ concurrency: number,
10
+ fn: (item: T, index: number) => Promise<R>,
11
+ ): Promise<R[]> {
12
+ const results: R[] = new Array(items.length)
13
+ let next = 0
14
+ const workerCount = Math.max(1, Math.min(concurrency, items.length))
15
+ const workers = Array.from({ length: workerCount }, async () => {
16
+ while (true) {
17
+ const i = next++
18
+ if (i >= items.length) return
19
+ results[i] = await fn(items[i] as T, i)
20
+ }
21
+ })
22
+ await Promise.all(workers)
23
+ return results
24
+ }
25
+
26
+ /**
27
+ * Counting semaphore with FIFO waiter queue.
28
+ *
29
+ * Use `run(fn)` to execute work under the gate — acquires before invoking,
30
+ * releases on resolve/reject. Suitable for bounding contention on a shared
31
+ * resource (e.g. concurrent LLM calls) regardless of how many call sites
32
+ * compete for it.
33
+ */
34
+ export class Semaphore {
35
+ private active = 0
36
+ private readonly waiters: Array<() => void> = []
37
+
38
+ constructor(private readonly limit: number) {
39
+ if (!Number.isInteger(limit) || limit < 1) {
40
+ throw new Error(`Semaphore limit must be a positive integer, got ${limit}`)
41
+ }
42
+ }
43
+
44
+ async run<T>(fn: () => Promise<T>): Promise<T> {
45
+ await this.acquire()
46
+ try {
47
+ return await fn()
48
+ } finally {
49
+ this.release()
50
+ }
51
+ }
52
+
53
+ private acquire(): Promise<void> {
54
+ if (this.active < this.limit) {
55
+ this.active++
56
+ return Promise.resolve()
57
+ }
58
+ return new Promise<void>(resolve => {
59
+ this.waiters.push(resolve)
60
+ })
61
+ }
62
+
63
+ private release(): void {
64
+ const next = this.waiters.shift()
65
+ if (next) {
66
+ // Slot transfers directly to the next waiter; active stays unchanged.
67
+ next()
68
+ } else {
69
+ this.active--
70
+ }
71
+ }
72
+ }
@@ -12,11 +12,12 @@
12
12
 
13
13
  import z from 'zod/v4'
14
14
  import { AgentId, agentIdSchema, generateAgentId } from '~/core/agents/schema.js'
15
- import { agentEvents } from '~/core/agents/state.js'
15
+ import { type AgentState, agentEvents } from '~/core/agents/state.js'
16
16
  import { AgentErrors, ValidationErrors } from '~/core/errors.js'
17
17
  import { definePlugin } from '~/core/plugins/index.js'
18
18
  import { getNextAgentSeq } from '~/core/sessions/state.js'
19
19
  import { createTool } from '~/core/tools/definition.js'
20
+ import type { Logger } from '~/lib/logger/logger.js'
20
21
  import { Err, Ok } from '~/lib/utils/result.js'
21
22
  import { mailboxPlugin } from '~/plugins/mailbox/plugin.js'
22
23
 
@@ -36,6 +37,132 @@ export interface SpawnableAgentInfo {
36
37
  export interface AgentsPluginConfig {
37
38
  /** Map of agent name → spawn info for generating typed tools */
38
39
  agentDefinitions: Map<string, SpawnableAgentInfo>
40
+ /**
41
+ * Supervision tick interval (ms) for parent agents. When set, parent agents
42
+ * with active children receive a periodic <children-status> snapshot via
43
+ * mailbox so they stay aware of long-running sub-agents and prompt cache
44
+ * stays warm.
45
+ *
46
+ * Default: undefined (disabled). Recommended: 240000 (4 min, just under
47
+ * the 5 min prompt cache TTL — see SUPERVISION_INTERVAL_CACHE_FRIENDLY).
48
+ */
49
+ superviseChildrenIntervalMs?: number
50
+ }
51
+
52
+ /**
53
+ * Recommended supervision interval — 4 min, just under prompt cache TTL.
54
+ * Each tick triggers a parent inference, keeping the prompt cache warm.
55
+ */
56
+ export const SUPERVISION_INTERVAL_CACHE_FRIENDLY = 240_000
57
+
58
+ /** Per-session runtime state held in plugin context — timers + trigger callback. */
59
+ interface AgentsPluginContext {
60
+ timers: Map<AgentId, ReturnType<typeof setTimeout>>
61
+ /** Set in onSessionReady — calls agents._supervisionTick via callPluginMethod (fresh ctx). */
62
+ triggerTick: ((agentId: AgentId) => Promise<unknown>) | null
63
+ /** null = supervision disabled for this session. */
64
+ intervalMs: number | null
65
+ logger: Logger | null
66
+ }
67
+
68
+ /**
69
+ * Get all direct children of an agent.
70
+ */
71
+ function getDirectChildren(sessionAgents: Map<AgentId, AgentState>, parentId: AgentId): AgentState[] {
72
+ const out: AgentState[] = []
73
+ for (const agent of sessionAgents.values()) {
74
+ if (agent.parentId === parentId) out.push(agent)
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * Count assistant tool calls across conversation history + currently pending.
81
+ */
82
+ function countToolCalls(state: AgentState): number {
83
+ let total = state.pendingToolCalls.length
84
+ for (const m of state.conversationHistory) {
85
+ if (m.role === 'assistant' && m.toolCalls) total += m.toolCalls.length
86
+ }
87
+ return total
88
+ }
89
+
90
+ /**
91
+ * Count completed LLM inferences (= assistant turns in history).
92
+ */
93
+ function countLLMCalls(state: AgentState): number {
94
+ let total = 0
95
+ for (const m of state.conversationHistory) {
96
+ if (m.role === 'assistant') total++
97
+ }
98
+ return total
99
+ }
100
+
101
+ /**
102
+ * Build a compact "first N words..last M words" preview of the agent's most
103
+ * recent assistant message (skipping empty turns). Returns null if none.
104
+ */
105
+ function previewLastAssistant(state: AgentState, headWords = 5, tailWords = 5): string | null {
106
+ for (let i = state.conversationHistory.length - 1; i >= 0; i--) {
107
+ const m = state.conversationHistory[i]
108
+ if (m.role !== 'assistant') continue
109
+ const text = m.content?.trim()
110
+ if (!text) continue
111
+ const words = text.split(/\s+/)
112
+ if (words.length <= headWords + tailWords + 1) return text
113
+ return `${words.slice(0, headWords).join(' ')}..${words.slice(-tailWords).join(' ')}`
114
+ }
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * Build a compact children-status snapshot for the given parent agent.
120
+ */
121
+ function buildChildrenStatus(sessionAgents: Map<AgentId, AgentState>, parentId: AgentId): string {
122
+ const children = getDirectChildren(sessionAgents, parentId)
123
+ const lines = children.map((c) => {
124
+ const tools = countToolCalls(c)
125
+ const llm = countLLMCalls(c)
126
+ const subs = getDirectChildren(sessionAgents, c.id).length
127
+ const last = previewLastAssistant(c)
128
+
129
+ const parts: string[] = [c.id, c.status]
130
+ parts.push(`${tools} tools`)
131
+ parts.push(`${llm} llm`)
132
+ if (subs > 0) parts.push(`${subs} sub${subs === 1 ? '' : 's'}`)
133
+ if (last) parts.push(`last "${last.replaceAll('"', "'")}"`)
134
+
135
+ return parts.join(', ')
136
+ })
137
+
138
+ return `<children-status>\n${lines.join('\n')}\n</children-status>`
139
+ }
140
+
141
+ /**
142
+ * (Re)schedule a supervision tick for an agent. Any existing timer is cleared first.
143
+ */
144
+ function scheduleSupervisionTick(
145
+ pluginContext: AgentsPluginContext,
146
+ agentId: AgentId,
147
+ delayMs: number,
148
+ ): void {
149
+ const existing = pluginContext.timers.get(agentId)
150
+ if (existing) clearTimeout(existing)
151
+
152
+ const timer = setTimeout(() => {
153
+ pluginContext.timers.delete(agentId)
154
+ const trigger = pluginContext.triggerTick
155
+ if (!trigger) return
156
+ trigger(agentId).catch((err) => {
157
+ pluginContext.logger?.error(
158
+ 'Supervision tick failed',
159
+ err instanceof Error ? err : undefined,
160
+ { agentId },
161
+ )
162
+ })
163
+ }, delayMs)
164
+
165
+ pluginContext.timers.set(agentId, timer)
39
166
  }
40
167
 
41
168
  /**
@@ -57,6 +184,12 @@ function createStartAgentSchema(agent: SpawnableAgentInfo) {
57
184
  export const agentsPlugin = definePlugin('agents')
58
185
  .pluginConfig<AgentsPluginConfig>()
59
186
  .dependencies([mailboxPlugin])
187
+ .context(async (): Promise<AgentsPluginContext> => ({
188
+ timers: new Map(),
189
+ triggerTick: null,
190
+ intervalMs: null,
191
+ logger: null,
192
+ }))
60
193
  .isEnabled((ctx) => {
61
194
  return ctx.agentConfig.spawnableAgents.length > 0
62
195
  })
@@ -108,6 +241,11 @@ export const agentsPlugin = definePlugin('agents')
108
241
  parentId: input.parentId,
109
242
  })
110
243
 
244
+ // Ensure parent has a supervision tick running now that it has a child.
245
+ if (ctx.pluginContext.intervalMs !== null) {
246
+ scheduleSupervisionTick(ctx.pluginContext, parentId, ctx.pluginContext.intervalMs)
247
+ }
248
+
111
249
  return Ok({ agentId })
112
250
  },
113
251
  })
@@ -196,12 +334,99 @@ export const agentsPlugin = definePlugin('agents')
196
334
  return Ok({})
197
335
  },
198
336
  })
199
- .systemPrompt(() => {
200
- return `## Working with Child Agents
337
+ .method('_supervisionTick', {
338
+ input: z.object({ agentId: agentIdSchema }),
339
+ output: z.object({}),
340
+ handler: async (ctx, input) => {
341
+ const agentId = AgentId(input.agentId)
342
+
343
+ // Self may already be gone (terminated mid-tick); just stop.
344
+ if (!ctx.sessionState.agents.has(agentId)) return Ok({})
345
+
346
+ const children = getDirectChildren(ctx.sessionState.agents, agentId)
347
+ if (children.length === 0) {
348
+ // No active children → don't reschedule. spawn() will re-arm if/when needed.
349
+ return Ok({})
350
+ }
351
+
352
+ const snapshot = buildChildrenStatus(ctx.sessionState.agents, agentId)
353
+ const sendResult = await ctx.deps.mailbox.send({
354
+ toAgentId: agentId,
355
+ content: snapshot,
356
+ fromSupervisor: true,
357
+ })
358
+ if (!sendResult.ok) {
359
+ ctx.logger.warn('Supervision snapshot send failed', {
360
+ agentId,
361
+ error: sendResult.error.message,
362
+ })
363
+ }
364
+
365
+ // Reschedule the next tick from now (rolling).
366
+ if (ctx.pluginContext.intervalMs !== null) {
367
+ scheduleSupervisionTick(ctx.pluginContext, agentId, ctx.pluginContext.intervalMs)
368
+ }
369
+
370
+ return Ok({})
371
+ },
372
+ })
373
+ .sessionHook('onSessionReady', async (ctx) => {
374
+ const intervalMs = ctx.pluginConfig.superviseChildrenIntervalMs
375
+ if (intervalMs === undefined) {
376
+ // Supervision disabled (default). No timer wiring; spawn() and
377
+ // afterInference() check intervalMs === null and skip too.
378
+ ctx.pluginContext.intervalMs = null
379
+ return
380
+ }
381
+ ctx.pluginContext.intervalMs = intervalMs
382
+ ctx.pluginContext.logger = ctx.logger
383
+
384
+ // Wire the trigger callback — calls back via self.* so each tick gets a
385
+ // fresh ctx (live sessionState/pluginState/deps).
386
+ ctx.pluginContext.triggerTick = (agentId) => ctx.self._supervisionTick({ agentId })
387
+
388
+ // (Re-)schedule timers for every agent that currently has direct children.
389
+ // Covers initial session creation AND server-restart reload (onSessionReady
390
+ // fires in both paths). Worst-case drift after restart = intervalMs.
391
+ for (const agent of ctx.sessionState.agents.values()) {
392
+ if (getDirectChildren(ctx.sessionState.agents, agent.id).length > 0) {
393
+ scheduleSupervisionTick(ctx.pluginContext, agent.id, intervalMs)
394
+ }
395
+ }
396
+ })
397
+ .sessionHook('onSessionClose', async (ctx) => {
398
+ for (const t of ctx.pluginContext.timers.values()) clearTimeout(t)
399
+ ctx.pluginContext.timers.clear()
400
+ ctx.pluginContext.triggerTick = null
401
+ })
402
+ .hook('afterInference', async (ctx) => {
403
+ // Natural inference warmed the cache — push the next tick out by intervalMs
404
+ // so we don't double-charge for parents who are already actively interacting.
405
+ if (ctx.pluginContext.intervalMs !== null) {
406
+ if (getDirectChildren(ctx.sessionState.agents, ctx.agentId).length > 0) {
407
+ scheduleSupervisionTick(ctx.pluginContext, ctx.agentId, ctx.pluginContext.intervalMs)
408
+ }
409
+ }
410
+ return null
411
+ })
412
+ .systemPrompt((ctx) => {
413
+ const base = `## Working with Child Agents
201
414
 
202
415
  - **New task** → spawn a new agent using \`start_<agent_name>\`. You will receive the agent's ID in the result — use it with \`send_message\` for follow-up communication.
203
416
  - **Follow-up on an existing task** → send a message to the existing agent via \`send_message\` with the agent's ID. Do NOT spawn a new agent for feedback, corrections, or additional instructions on a task already assigned.
204
417
  - Spawned agents communicate back to you via \`send_message\`. Check your incoming messages for their results and progress updates.`
418
+
419
+ // Only include supervision instructions if supervision is actually enabled
420
+ // for this session — otherwise the section is misleading bloat.
421
+ if (ctx.pluginContext.intervalMs === null) return base
422
+
423
+ return `${base}
424
+
425
+ ### Supervision messages
426
+
427
+ You will periodically receive a \`<children-status>\` message from \`from="supervisor"\`. It is a status snapshot of your direct children — purely informational. Per child you'll see status, cumulative tool/llm call counts, sub-agent count, and a "first words..last words" preview of their last assistant turn.
428
+
429
+ Do NOT act on a supervision tick unless something is genuinely wrong (a child has been errored or stuck for a long time, you have a deadline, etc.). Most of the time you should just wait. Never reply to the supervisor.`
205
430
  })
206
431
  .tools((ctx) => {
207
432
  const spawnableAgents = ctx.agentConfig.spawnableAgents