@roj-ai/sdk 0.1.24 → 0.1.26
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/core/agents/agent.d.ts +6 -0
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +25 -1
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +4 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/plugin-hooks.integration.test.js +114 -0
- package/dist/core/plugin-hooks.integration.test.js.map +1 -1
- package/dist/core/plugins/hook-types.d.ts +19 -0
- package/dist/core/plugins/hook-types.d.ts.map +1 -1
- package/dist/core/plugins/plugin-builder.d.ts +28 -1
- package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
- package/dist/core/plugins/plugin-builder.js +19 -0
- package/dist/core/plugins/plugin-builder.js.map +1 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +25 -1
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +19 -2
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +12 -1
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +22 -1
- package/src/core/errors.ts +5 -0
- package/src/core/plugin-hooks.integration.test.ts +140 -0
- package/src/core/plugins/hook-types.ts +20 -0
- package/src/core/plugins/plugin-builder.ts +31 -1
- package/src/core/sessions/session.ts +24 -1
- package/src/plugins/services/schema.ts +20 -2
- package/src/plugins/services/service.ts +12 -1
|
@@ -17,5 +17,5 @@ export declare const errorHandler: (error: Error, c: Context<AppEnv>) => Respons
|
|
|
17
17
|
type: string;
|
|
18
18
|
message: string;
|
|
19
19
|
};
|
|
20
|
-
}, 100 | 500 | 404 | 409 | 400 | -1 | 429 | 401 | 200 | 501 | 201 | 300 | 102 | 103 | 202 | 203 | 206 | 207 | 208 | 226 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 402 |
|
|
20
|
+
}, 100 | 500 | 404 | 409 | 400 | 403 | -1 | 429 | 401 | 200 | 501 | 201 | 300 | 102 | 103 | 202 | 203 | 206 | 207 | 208 | 226 | 301 | 302 | 303 | 305 | 306 | 307 | 308 | 402 | 405 | 406 | 407 | 408 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 431 | 451 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, "json">;
|
|
21
21
|
//# sourceMappingURL=error-handler.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roj-ai/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
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.26",
|
|
139
139
|
"@hono/zod-validator": "0.7.6",
|
|
140
140
|
"hono": "4.12.5",
|
|
141
141
|
"ignore": "7.0.5",
|
package/src/core/agents/agent.ts
CHANGED
|
@@ -1606,7 +1606,25 @@ export class Agent {
|
|
|
1606
1606
|
for (const plugin of this.plugins) {
|
|
1607
1607
|
if (!plugin.dequeue) continue
|
|
1608
1608
|
const ctx = this.buildPluginHookContext(plugin, agentContext)
|
|
1609
|
-
if (plugin.dequeue.hasPendingMessages(ctx))
|
|
1609
|
+
if (!plugin.dequeue.hasPendingMessages(ctx)) continue
|
|
1610
|
+
// A beforeDequeue gate may hold this source's messages — held messages
|
|
1611
|
+
// must not count as work, or the agent would wake with nothing to do.
|
|
1612
|
+
if (this.isDequeueHeld(plugin.name, agentContext)) continue
|
|
1613
|
+
return true
|
|
1614
|
+
}
|
|
1615
|
+
return false
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Run all plugins' beforeDequeue gates for a source plugin's pending messages.
|
|
1620
|
+
* Returns true if any gate holds them — held messages stay queued (not
|
|
1621
|
+
* consumed) and are re-delivered on a later cycle.
|
|
1622
|
+
*/
|
|
1623
|
+
private isDequeueHeld(sourcePlugin: string, agentContext: AgentContext): boolean {
|
|
1624
|
+
for (const plugin of this.plugins) {
|
|
1625
|
+
if (!plugin.beforeDequeue) continue
|
|
1626
|
+
const ctx = { ...this.buildPluginHookContext(plugin, agentContext), sourcePlugin }
|
|
1627
|
+
if (plugin.beforeDequeue(ctx)?.action === 'hold') return true
|
|
1610
1628
|
}
|
|
1611
1629
|
return false
|
|
1612
1630
|
}
|
|
@@ -1632,6 +1650,9 @@ export class Agent {
|
|
|
1632
1650
|
|
|
1633
1651
|
for (const plugin of this.plugins) {
|
|
1634
1652
|
if (!plugin.dequeue) continue
|
|
1653
|
+
// Skip sources held by a beforeDequeue gate — not collected means not
|
|
1654
|
+
// consumed, so the messages stay queued for a later cycle.
|
|
1655
|
+
if (this.isDequeueHeld(plugin.name, agentContext)) continue
|
|
1635
1656
|
const ctx = this.buildPluginHookContext(plugin, agentContext)
|
|
1636
1657
|
const result = plugin.dequeue.getPendingMessages(ctx)
|
|
1637
1658
|
if (result) {
|
package/src/core/errors.ts
CHANGED
|
@@ -53,3 +53,8 @@ export const PresetErrors = {
|
|
|
53
53
|
export const ValidationErrors = {
|
|
54
54
|
invalid: (message: string) => createDomainError('validation_error', message, 400),
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export const MethodErrors = {
|
|
58
|
+
/** A `beforeMethod` plugin hook denied the call (e.g. a budget guard blocking new user input). */
|
|
59
|
+
denied: (message: string) => createDomainError('method_denied', message, 403),
|
|
60
|
+
}
|
|
@@ -4,7 +4,9 @@ import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
|
4
4
|
import { definePlugin } from '~/core/plugins/plugin-builder.js'
|
|
5
5
|
import { ToolCallId } from '~/core/tools/schema.js'
|
|
6
6
|
import { toolEvents } from '~/core/tools/state.js'
|
|
7
|
+
import { Ok } from '~/lib/utils/result.js'
|
|
7
8
|
import { createTestPreset, TestHarness } from '~/testing/index.js'
|
|
9
|
+
import z from 'zod/v4'
|
|
8
10
|
|
|
9
11
|
describe('core: plugin hooks', () => {
|
|
10
12
|
// =========================================================================
|
|
@@ -373,6 +375,80 @@ describe('core: plugin hooks', () => {
|
|
|
373
375
|
|
|
374
376
|
await harness.shutdown()
|
|
375
377
|
})
|
|
378
|
+
|
|
379
|
+
describe('beforeMethod', () => {
|
|
380
|
+
it('returning { action: "deny" } → method call rejected and handler not run', async () => {
|
|
381
|
+
let handlerCalls = 0
|
|
382
|
+
const seenMethods: string[] = []
|
|
383
|
+
|
|
384
|
+
const gatedPlugin = definePlugin('gated')
|
|
385
|
+
.method('blocked', {
|
|
386
|
+
input: z.object({}),
|
|
387
|
+
output: z.object({ ok: z.boolean() }),
|
|
388
|
+
handler: async () => {
|
|
389
|
+
handlerCalls++
|
|
390
|
+
return Ok({ ok: true })
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
.sessionHook('beforeMethod', async (ctx) => {
|
|
394
|
+
seenMethods.push(ctx.method)
|
|
395
|
+
if (ctx.method === 'gated.blocked') {
|
|
396
|
+
return { action: 'deny', reason: 'blocked by test' }
|
|
397
|
+
}
|
|
398
|
+
return null
|
|
399
|
+
})
|
|
400
|
+
.build()
|
|
401
|
+
|
|
402
|
+
const harness = new TestHarness({
|
|
403
|
+
presets: [createTestPreset()],
|
|
404
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
405
|
+
systemPlugins: [gatedPlugin],
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const session = await harness.createSession('test')
|
|
409
|
+
const result = await session.callPluginMethod('gated.blocked', {})
|
|
410
|
+
|
|
411
|
+
expect(result.ok).toBe(false)
|
|
412
|
+
if (!result.ok) {
|
|
413
|
+
expect(result.error.type).toBe('method_denied')
|
|
414
|
+
expect(result.error.message).toBe('blocked by test')
|
|
415
|
+
}
|
|
416
|
+
expect(handlerCalls).toBe(0)
|
|
417
|
+
expect(seenMethods).toContain('gated.blocked')
|
|
418
|
+
|
|
419
|
+
await harness.shutdown()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('returning null → method call proceeds to the handler', async () => {
|
|
423
|
+
let handlerCalls = 0
|
|
424
|
+
|
|
425
|
+
const gatedPlugin = definePlugin('gated')
|
|
426
|
+
.method('allowed', {
|
|
427
|
+
input: z.object({}),
|
|
428
|
+
output: z.object({ ok: z.boolean() }),
|
|
429
|
+
handler: async () => {
|
|
430
|
+
handlerCalls++
|
|
431
|
+
return Ok({ ok: true })
|
|
432
|
+
},
|
|
433
|
+
})
|
|
434
|
+
.sessionHook('beforeMethod', async () => null)
|
|
435
|
+
.build()
|
|
436
|
+
|
|
437
|
+
const harness = new TestHarness({
|
|
438
|
+
presets: [createTestPreset()],
|
|
439
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
440
|
+
systemPlugins: [gatedPlugin],
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const session = await harness.createSession('test')
|
|
444
|
+
const result = await session.callPluginMethod('gated.allowed', {})
|
|
445
|
+
|
|
446
|
+
expect(result.ok).toBe(true)
|
|
447
|
+
expect(handlerCalls).toBe(1)
|
|
448
|
+
|
|
449
|
+
await harness.shutdown()
|
|
450
|
+
})
|
|
451
|
+
})
|
|
376
452
|
})
|
|
377
453
|
|
|
378
454
|
// =========================================================================
|
|
@@ -425,4 +501,68 @@ describe('core: plugin hooks', () => {
|
|
|
425
501
|
await harness.shutdown()
|
|
426
502
|
})
|
|
427
503
|
})
|
|
504
|
+
|
|
505
|
+
// =========================================================================
|
|
506
|
+
// beforeDequeue gate
|
|
507
|
+
// =========================================================================
|
|
508
|
+
|
|
509
|
+
describe('beforeDequeue', () => {
|
|
510
|
+
it('holding a source → messages do not wake the agent, and are re-delivered (not dropped) once released', async () => {
|
|
511
|
+
let holding = true
|
|
512
|
+
|
|
513
|
+
const gatePlugin = definePlugin('budget-gate')
|
|
514
|
+
.beforeDequeue((ctx) => {
|
|
515
|
+
if (holding && ctx.sourcePlugin === 'user-chat') {
|
|
516
|
+
return { action: 'hold' }
|
|
517
|
+
}
|
|
518
|
+
return null
|
|
519
|
+
})
|
|
520
|
+
.build()
|
|
521
|
+
|
|
522
|
+
const harness = new TestHarness({
|
|
523
|
+
presets: [createTestPreset()],
|
|
524
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
525
|
+
systemPlugins: [gatePlugin],
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
const session = await harness.createSession('test')
|
|
529
|
+
|
|
530
|
+
// Held: the user message is queued but must not trigger an inference.
|
|
531
|
+
await session.sendMessage('first')
|
|
532
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
533
|
+
expect(harness.llmProvider.getLastRequest()).toBeUndefined()
|
|
534
|
+
|
|
535
|
+
// Released: a new message wakes the agent; the held 'first' must be
|
|
536
|
+
// re-delivered alongside 'second' (held, not dropped).
|
|
537
|
+
holding = false
|
|
538
|
+
await session.sendAndWaitForIdle('second')
|
|
539
|
+
|
|
540
|
+
const req = harness.llmProvider.getLastRequest()
|
|
541
|
+
expect(req).toBeDefined()
|
|
542
|
+
const serialized = JSON.stringify(req!.messages)
|
|
543
|
+
expect(serialized).toContain('first')
|
|
544
|
+
expect(serialized).toContain('second')
|
|
545
|
+
|
|
546
|
+
await harness.shutdown()
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('returning null → messages flow through normally', async () => {
|
|
550
|
+
const gatePlugin = definePlugin('budget-gate')
|
|
551
|
+
.beforeDequeue(() => null)
|
|
552
|
+
.build()
|
|
553
|
+
|
|
554
|
+
const harness = new TestHarness({
|
|
555
|
+
presets: [createTestPreset()],
|
|
556
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
557
|
+
systemPlugins: [gatePlugin],
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
const session = await harness.createSession('test')
|
|
561
|
+
await session.sendAndWaitForIdle('hello')
|
|
562
|
+
|
|
563
|
+
expect(harness.llmProvider.getLastRequest()).toBeDefined()
|
|
564
|
+
|
|
565
|
+
await harness.shutdown()
|
|
566
|
+
})
|
|
567
|
+
})
|
|
428
568
|
})
|
|
@@ -41,6 +41,26 @@ export type OnErrorResult =
|
|
|
41
41
|
|
|
42
42
|
export type OnPauseResult = null
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Result of a `beforeMethod` session hook — lets a plugin veto a plugin-method
|
|
46
|
+
* call before its handler runs (e.g. a budget guard blocking new user messages
|
|
47
|
+
* while allowing internal agent-to-agent calls).
|
|
48
|
+
*/
|
|
49
|
+
export type BeforeMethodResult =
|
|
50
|
+
| null
|
|
51
|
+
| { action: 'deny'; reason: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Result of a `beforeDequeue` hook — lets a plugin HOLD (not deliver) the
|
|
55
|
+
* pending messages of a given source plugin for the current cycle. Held
|
|
56
|
+
* messages stay queued (not consumed) and are re-delivered on a later cycle —
|
|
57
|
+
* e.g. a budget guard holding new user/upload input while letting inter-agent
|
|
58
|
+
* mailbox traffic through so running agents can finish.
|
|
59
|
+
*/
|
|
60
|
+
export type BeforeDequeueResult =
|
|
61
|
+
| null
|
|
62
|
+
| { action: 'hold' }
|
|
63
|
+
|
|
44
64
|
export type HandlerName =
|
|
45
65
|
| 'onStart'
|
|
46
66
|
| 'beforeInference'
|
|
@@ -26,7 +26,9 @@ import type { ToolCall } from '../tools/schema.js'
|
|
|
26
26
|
import type {
|
|
27
27
|
AfterInferenceResult,
|
|
28
28
|
AfterToolCallResult,
|
|
29
|
+
BeforeDequeueResult,
|
|
29
30
|
BeforeInferenceResult,
|
|
31
|
+
BeforeMethodResult,
|
|
30
32
|
BeforeToolCallResult,
|
|
31
33
|
OnCompleteResult,
|
|
32
34
|
OnErrorResult,
|
|
@@ -237,6 +239,7 @@ type HookMap<TCtx> = {
|
|
|
237
239
|
type SessionHookMap<TCtx> = {
|
|
238
240
|
onSessionReady: (ctx: TCtx) => Promise<void>
|
|
239
241
|
onSessionClose: (ctx: TCtx) => Promise<void>
|
|
242
|
+
beforeMethod: (ctx: TCtx & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
// ============================================================================
|
|
@@ -311,6 +314,7 @@ type ErasedAgentHookMap = {
|
|
|
311
314
|
type ErasedSessionHookMap = {
|
|
312
315
|
onSessionReady: (ctx: BaseSessionHookContext) => Promise<void>
|
|
313
316
|
onSessionClose: (ctx: BaseSessionHookContext) => Promise<void>
|
|
317
|
+
beforeMethod: (ctx: BaseSessionHookContext & { method: string; input: unknown; agentId?: AgentId }) => Promise<BeforeMethodResult>
|
|
314
318
|
}
|
|
315
319
|
|
|
316
320
|
// ============================================================================
|
|
@@ -351,6 +355,8 @@ export interface ConfiguredPlugin {
|
|
|
351
355
|
getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
|
|
352
356
|
markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
|
|
353
357
|
}
|
|
358
|
+
/** Gate run before delivering another plugin's dequeued messages — return 'hold' to keep them queued this cycle. */
|
|
359
|
+
beforeDequeue?: (ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult
|
|
354
360
|
slice?: StateSlice
|
|
355
361
|
isEnabled?: (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => boolean
|
|
356
362
|
isSessionEnabled?: (ctx: { pluginConfig: unknown }) => boolean
|
|
@@ -443,6 +449,7 @@ interface BuilderConfig {
|
|
|
443
449
|
getPendingMessages: (ctx: BasePluginHookContext) => PluginPendingMessages | null
|
|
444
450
|
markConsumed: (ctx: BasePluginHookContext, token: unknown) => Promise<void>
|
|
445
451
|
} | undefined
|
|
452
|
+
beforeDequeueFn: ((ctx: BasePluginHookContext & { sourcePlugin: string }) => BeforeDequeueResult) | undefined
|
|
446
453
|
}
|
|
447
454
|
|
|
448
455
|
// ============================================================================
|
|
@@ -486,6 +493,7 @@ export class PluginBuilder<
|
|
|
486
493
|
sessionHooks: {},
|
|
487
494
|
isEnabledFn: undefined,
|
|
488
495
|
dequeueHook: undefined,
|
|
496
|
+
beforeDequeueFn: undefined,
|
|
489
497
|
}
|
|
490
498
|
}
|
|
491
499
|
|
|
@@ -748,6 +756,20 @@ export class PluginBuilder<
|
|
|
748
756
|
return this
|
|
749
757
|
}
|
|
750
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Gate the delivery of OTHER plugins' dequeued messages. Runs once per source
|
|
761
|
+
* plugin that has pending messages, before they wake the agent; return
|
|
762
|
+
* `{ action: 'hold' }` to keep that source's messages queued this cycle
|
|
763
|
+
* (re-delivered later). `ctx.sourcePlugin` is the name of the plugin whose
|
|
764
|
+
* messages are being considered.
|
|
765
|
+
*/
|
|
766
|
+
beforeDequeue(
|
|
767
|
+
fn: (ctx: PluginHookContext<TConfig, TMethods, TAgentConfig, TContext, TState, TNotifications, TDeps> & { sourcePlugin: string }) => BeforeDequeueResult,
|
|
768
|
+
): this {
|
|
769
|
+
this._cfg.beforeDequeueFn = fn as BuilderConfig['beforeDequeueFn']
|
|
770
|
+
return this
|
|
771
|
+
}
|
|
772
|
+
|
|
751
773
|
// --- Build ---
|
|
752
774
|
|
|
753
775
|
build(): PluginDefinition<TName, TConfig, TAgentConfig, TManagerMethods, TMethods> {
|
|
@@ -811,11 +833,13 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
|
|
|
811
833
|
) as Partial<ErasedAgentHookMap>
|
|
812
834
|
: undefined
|
|
813
835
|
|
|
814
|
-
const sessionHookEntries = Object.entries(cfg.sessionHooks)
|
|
836
|
+
const sessionHookEntries = Object.entries(cfg.sessionHooks) as [string, (ctx: BaseSessionHookContext) => Promise<unknown>][]
|
|
815
837
|
const sessionHooks: Partial<ErasedSessionHookMap> | undefined = sessionHookEntries.length > 0
|
|
816
838
|
? Object.fromEntries(
|
|
817
839
|
sessionHookEntries.map(([name, fn]) => [
|
|
818
840
|
name,
|
|
841
|
+
// Session.ts provides the full context with extra fields (method, input,
|
|
842
|
+
// agentId for beforeMethod) — this wrapper just injects pluginConfig.
|
|
819
843
|
(ctx: BaseSessionHookContext) => fn({ ...ctx, pluginConfig }),
|
|
820
844
|
]),
|
|
821
845
|
) as Partial<ErasedSessionHookMap>
|
|
@@ -872,6 +896,11 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
|
|
|
872
896
|
}
|
|
873
897
|
: undefined
|
|
874
898
|
|
|
899
|
+
const beforeDequeueFn = cfg.beforeDequeueFn
|
|
900
|
+
const wrappedBeforeDequeue = beforeDequeueFn
|
|
901
|
+
? (ctx: BasePluginHookContext & { sourcePlugin: string }) => beforeDequeueFn({ ...ctx, pluginConfig })
|
|
902
|
+
: undefined
|
|
903
|
+
|
|
875
904
|
// Wrap isEnabled to pass pluginConfig
|
|
876
905
|
const wrappedIsEnabled = cfg.isEnabledFn
|
|
877
906
|
? (ctx: { pluginConfig: unknown; pluginAgentConfig: unknown; agentConfig: AgentConfig }) => {
|
|
@@ -916,6 +945,7 @@ function buildConfiguredPlugin(cfg: BuilderConfig, pluginConfig: unknown): Confi
|
|
|
916
945
|
getStatus: wrappedStatus,
|
|
917
946
|
getSystemPrompt: wrappedSystemPrompt,
|
|
918
947
|
dequeue: wrappedDequeue,
|
|
948
|
+
beforeDequeue: wrappedBeforeDequeue,
|
|
919
949
|
slice,
|
|
920
950
|
isEnabled: wrappedIsEnabled,
|
|
921
951
|
isSessionEnabled: wrappedIsSessionEnabled,
|
|
@@ -12,7 +12,7 @@ import { COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.
|
|
|
12
12
|
import { AgentId, generateAgentId } from '~/core/agents/schema.js'
|
|
13
13
|
import type { AgentState } from '~/core/agents/state.js'
|
|
14
14
|
import { agentEvents, getChildren } from '~/core/agents/state.js'
|
|
15
|
-
import { AgentErrors, type DomainError, SessionErrors, ValidationErrors } from '~/core/errors.js'
|
|
15
|
+
import { AgentErrors, type DomainError, MethodErrors, SessionErrors, ValidationErrors } from '~/core/errors.js'
|
|
16
16
|
import { withSessionId } from '~/core/events/test-helpers.js'
|
|
17
17
|
import type { DomainEvent } from '~/core/events/types.js'
|
|
18
18
|
import type { LLMLogger } from '~/core/llm/logger.js'
|
|
@@ -478,6 +478,29 @@ export class Session {
|
|
|
478
478
|
return Err(ValidationErrors.invalid(`Invalid input for ${method}: ${parsed.error.message}`))
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
// beforeMethod gate — any plugin may veto this call before the handler runs
|
|
482
|
+
// (e.g. a budget guard blocking new user input). The hook receives `caller`,
|
|
483
|
+
// so a gate can allow internal AGENT_CALLER traffic (agent-to-agent) while
|
|
484
|
+
// denying external/user-originated calls. First deny wins.
|
|
485
|
+
for (const gatePlugin of this.plugins) {
|
|
486
|
+
if (!gatePlugin.sessionHooks?.beforeMethod) continue
|
|
487
|
+
try {
|
|
488
|
+
const gateCtx = {
|
|
489
|
+
...this.buildSessionHookContext(gatePlugin),
|
|
490
|
+
caller: caller ?? DEFAULT_CALLER,
|
|
491
|
+
method,
|
|
492
|
+
input: parsed.data,
|
|
493
|
+
agentId,
|
|
494
|
+
}
|
|
495
|
+
const gate = await gatePlugin.sessionHooks.beforeMethod(gateCtx)
|
|
496
|
+
if (gate?.action === 'deny') {
|
|
497
|
+
return Err(MethodErrors.denied(gate.reason))
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
this.logger.error(`Plugin '${gatePlugin.name}' beforeMethod hook failed`, err instanceof Error ? err : undefined, { method })
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
481
504
|
// Build MethodHandlerContext with plugin state, context, scheduleAgent, notify, and deps
|
|
482
505
|
const sessionContext = this.buildSessionContext()
|
|
483
506
|
const pluginState = plugin.slice ? plugin.slice.select(this.store.getState()) : undefined
|
|
@@ -26,6 +26,16 @@ export interface ServiceCommandArgs {
|
|
|
26
26
|
port: number
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Arguments passed to a `cwd` resolver callback. Resolved lazily at service
|
|
31
|
+
* start, so the callback can inspect the (by-then-populated) workspace — e.g.
|
|
32
|
+
* to locate the web app inside a monorepo before launching the dev server.
|
|
33
|
+
*/
|
|
34
|
+
export interface ServiceCwdArgs {
|
|
35
|
+
/** Absolute path of the session workspace directory. */
|
|
36
|
+
workspaceDir: string
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
40
|
* Service configuration declared inline in presets.
|
|
31
41
|
* Defines how to spawn and monitor a background process.
|
|
@@ -37,8 +47,16 @@ export interface ServiceConfig {
|
|
|
37
47
|
description: string
|
|
38
48
|
/** Shell command to run, or a callback receiving allocated port */
|
|
39
49
|
command: string | ((args: ServiceCommandArgs) => string)
|
|
40
|
-
/**
|
|
41
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Working directory (defaults to the workspace dir).
|
|
52
|
+
*
|
|
53
|
+
* A string is resolved relative to the workspace dir (absolute paths are
|
|
54
|
+
* used as-is). A callback is invoked at service start with the workspace
|
|
55
|
+
* dir, so the directory can be detected lazily once the workspace is
|
|
56
|
+
* populated (e.g. the web package inside a monorepo); its return value is
|
|
57
|
+
* resolved the same way.
|
|
58
|
+
*/
|
|
59
|
+
cwd?: string | ((args: ServiceCwdArgs) => string | Promise<string>)
|
|
42
60
|
/** Additional environment variables */
|
|
43
61
|
env?: Record<string, string>
|
|
44
62
|
/** Start automatically with session (default: false) */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Ports are allocated from a global PortPool and injected via PORT env var.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { resolve } from 'node:path'
|
|
9
10
|
import type { SessionId } from '~/core/sessions/schema.js'
|
|
10
11
|
import type { Result } from '~/lib/utils/result.js'
|
|
11
12
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
@@ -224,7 +225,17 @@ export class ServiceExecutor {
|
|
|
224
225
|
this.allocatedPorts.set(config.type, port)
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
// Resolve cwd: a callback is invoked lazily at start (the workspace is
|
|
229
|
+
// populated by now), a string is used directly. Either result is resolved
|
|
230
|
+
// against the workspace dir so a relative path (e.g. 'packages/web' for a
|
|
231
|
+
// monorepo) lands inside this session's worktree; absolute paths pass through.
|
|
232
|
+
let cwd = workspaceDir
|
|
233
|
+
if (config.cwd !== undefined) {
|
|
234
|
+
const raw = typeof config.cwd === 'function'
|
|
235
|
+
? await config.cwd({ workspaceDir: workspaceDir ?? process.cwd() })
|
|
236
|
+
: config.cwd
|
|
237
|
+
cwd = workspaceDir ? resolve(workspaceDir, raw) : resolve(raw)
|
|
238
|
+
}
|
|
228
239
|
const logBufferSize = config.logBufferSize ?? 200
|
|
229
240
|
const startupTimeoutMs = config.startupTimeoutMs ?? 30_000
|
|
230
241
|
|