@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.
- package/dist/bootstrap.d.ts +18 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +3 -1
- package/dist/bootstrap.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +13 -5
- package/dist/core/sessions/session-manager.js.map +1 -1
- package/dist/lib/utils/concurrency.d.ts +25 -0
- package/dist/lib/utils/concurrency.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.js +69 -0
- package/dist/lib/utils/concurrency.js.map +1 -0
- package/dist/lib/utils/concurrency.test.d.ts +2 -0
- package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.test.js +135 -0
- package/dist/lib/utils/concurrency.test.js.map +1 -0
- package/dist/plugins/agents/plugin.d.ts +20 -0
- package/dist/plugins/agents/plugin.d.ts.map +1 -1
- package/dist/plugins/agents/plugin.js +189 -2
- package/dist/plugins/agents/plugin.js.map +1 -1
- package/dist/plugins/agents/supervision.integration.test.d.ts +2 -0
- package/dist/plugins/agents/supervision.integration.test.d.ts.map +1 -0
- package/dist/plugins/agents/supervision.integration.test.js +215 -0
- package/dist/plugins/agents/supervision.integration.test.js.map +1 -0
- package/dist/plugins/mailbox/plugin.d.ts +1 -0
- package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
- package/dist/plugins/mailbox/plugin.js +17 -0
- package/dist/plugins/mailbox/plugin.js.map +1 -1
- package/dist/plugins/mailbox/schema.d.ts +1 -1
- package/dist/plugins/mailbox/schema.d.ts.map +1 -1
- package/dist/plugins/mailbox/state.d.ts +2 -1
- package/dist/plugins/mailbox/state.d.ts.map +1 -1
- package/dist/plugins/mailbox/state.js +1 -1
- package/dist/plugins/mailbox/state.js.map +1 -1
- package/dist/plugins/uploads/plugin.d.ts +12 -0
- package/dist/plugins/uploads/plugin.d.ts.map +1 -1
- package/dist/plugins/uploads/plugin.js +188 -44
- package/dist/plugins/uploads/plugin.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/state.d.ts +1 -0
- package/dist/plugins/uploads/state.d.ts.map +1 -1
- package/dist/plugins/uploads/state.js +1 -1
- package/dist/plugins/uploads/state.js.map +1 -1
- package/dist/plugins/uploads/uploads.integration.test.js +97 -0
- package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/dist/transport/http/routes/upload.d.ts.map +1 -1
- package/dist/transport/http/routes/upload.js +60 -0
- package/dist/transport/http/routes/upload.js.map +1 -1
- package/dist/user-config.d.ts +14 -0
- package/dist/user-config.d.ts.map +1 -1
- package/dist/user-config.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +3 -1
- package/src/config.ts +6 -0
- package/src/core/sessions/session-manager.ts +14 -5
- package/src/lib/utils/concurrency.test.ts +169 -0
- package/src/lib/utils/concurrency.ts +72 -0
- package/src/plugins/agents/plugin.ts +228 -3
- package/src/plugins/agents/supervision.integration.test.ts +249 -0
- package/src/plugins/mailbox/plugin.ts +20 -0
- package/src/plugins/mailbox/schema.ts +1 -0
- package/src/plugins/mailbox/state.ts +2 -1
- package/src/plugins/uploads/plugin.ts +212 -47
- package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
- package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
- package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
- package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
- package/src/plugins/uploads/state.ts +1 -1
- package/src/plugins/uploads/uploads.integration.test.ts +123 -0
- package/src/transport/http/routes/upload.ts +87 -0
- package/src/user-config.ts +15 -0
package/dist/user-config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-config.js","sourceRoot":"","sources":["../src/user-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
685
|
-
|
|
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 (
|
|
688
|
-
config =
|
|
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
|
-
.
|
|
200
|
-
|
|
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
|