@dxos/agent-runtime 0.13.0 → 0.14.0

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 (32) hide show
  1. package/dist/lib/chunk-AgentService.mjs +189 -93
  2. package/dist/lib/chunk-AgentService.mjs.map +1 -1
  3. package/dist/lib/testing.mjs +3 -3
  4. package/dist/lib/testing.mjs.map +1 -1
  5. package/dist/types/src/agent-service/AgentService.d.ts +9 -0
  6. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -1
  7. package/dist/types/src/agent-service/agent-process.d.ts +23 -5
  8. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -1
  9. package/dist/types/src/agent-service/delegation-strategy.d.ts +5 -4
  10. package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -1
  11. package/dist/types/src/agent-service/index.d.ts +1 -1
  12. package/dist/types/src/agent-service/index.d.ts.map +1 -1
  13. package/dist/types/src/agent-service/process-selection.test.d.ts +2 -0
  14. package/dist/types/src/agent-service/process-selection.test.d.ts.map +1 -0
  15. package/dist/types/src/agent-service/skill-hooks.test.d.ts +2 -0
  16. package/dist/types/src/agent-service/skill-hooks.test.d.ts.map +1 -0
  17. package/dist/types/src/testing/assistant-test-layer.d.ts +2 -2
  18. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -1
  19. package/dist/types/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +17 -17
  21. package/src/agent-service/AgentService.test.ts +4 -4
  22. package/src/agent-service/AgentService.ts +63 -24
  23. package/src/agent-service/README.md +10 -10
  24. package/src/agent-service/agent-process.ts +201 -100
  25. package/src/agent-service/delegation-scripted.test.ts +4 -4
  26. package/src/agent-service/delegation-strategy.ts +9 -4
  27. package/src/agent-service/index.ts +1 -0
  28. package/src/agent-service/process-selection.test.ts +135 -0
  29. package/src/agent-service/queue-scripted.test.ts +39 -0
  30. package/src/agent-service/redelivery.test.ts +23 -1
  31. package/src/agent-service/skill-hooks.test.ts +182 -0
  32. package/src/testing/assistant-test-layer.ts +3 -2
@@ -0,0 +1,135 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, it } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Exit from 'effect/Exit';
8
+ import * as Option from 'effect/Option';
9
+ import * as Schema from 'effect/Schema';
10
+ import { expect } from 'vitest';
11
+
12
+ import { HarnessControl } from '@dxos/assistant';
13
+ import * as Chat from '@dxos/assistant/Chat';
14
+ import * as AgentService from '@dxos/compute/AgentService';
15
+ import * as Operation from '@dxos/compute/Operation';
16
+ import * as Process from '@dxos/compute/Process';
17
+ import { Annotation, Database, Feed, Obj, Ref } from '@dxos/echo';
18
+ import { TestHelpers } from '@dxos/effect/testing';
19
+ import { Message } from '@dxos/types';
20
+
21
+ import { AssistantTestLayer, waitForMessage } from '../testing/index.ts';
22
+ import { AGENT_PROCESS_KEY, AgentInput, type AgentProcessDefinition, makeInputMessage } from './agent-process.ts';
23
+
24
+ const ECHO_PROCESS_KEY = 'com.example.process.echo';
25
+
26
+ /** Answers each prompt with its own text, so a test can tell which process ran the chat. */
27
+ const EchoProcess: AgentProcessDefinition = Operation.makeDurable({
28
+ key: ECHO_PROCESS_KEY,
29
+ input: AgentInput,
30
+ output: Schema.Void,
31
+ types: [Chat.Chat, Feed.Feed, Message.Message],
32
+ services: [Database.Service],
33
+ rpcs: HarnessControl,
34
+ }).pipe(
35
+ Operation.withDurableHandler((ctx) =>
36
+ Effect.gen(function* () {
37
+ const chatDxn = Option.getOrThrow(Annotation.getDictionary(ctx.params.annotations, Process.TargetAnnotation));
38
+ const chat = yield* Database.resolve(chatDxn, Chat.Chat).pipe(Effect.orDie);
39
+ const feed = yield* Database.load(chat.feed).pipe(Effect.orDie);
40
+ const reply = (text: string) =>
41
+ Feed.append(feed, [
42
+ Message.make({ sender: { role: 'assistant' }, blocks: [{ _tag: 'text', text: `echo: ${text}` }] }),
43
+ ]);
44
+ return {
45
+ rpcHandlers: yield* HarnessControl.toHandlers({
46
+ setAlarm: () => Effect.void,
47
+ enqueueMessage: () => Effect.void,
48
+ }),
49
+ onInput: (input) => reply(Message.extractText(makeInputMessage(input))),
50
+ };
51
+ }),
52
+ ),
53
+ );
54
+
55
+ const TestLayer = AssistantTestLayer({
56
+ types: [Feed.Feed],
57
+ agent: { processes: () => [EchoProcess] },
58
+ });
59
+
60
+ const makeChat = Effect.fnUntraced(function* (process?: string) {
61
+ const feed = yield* Database.add(Feed.make());
62
+ return yield* Database.add(Chat.make({ feed: Ref.make(feed), ...(process ? { session: { process } } : {}) }));
63
+ });
64
+
65
+ const runningKeys = Effect.gen(function* () {
66
+ const manager = yield* Process.ManagerService;
67
+ const processes = yield* manager.handles();
68
+ return processes.map((process) => process.key);
69
+ });
70
+
71
+ describe('AgentService process selection', () => {
72
+ it.effect(
73
+ 'runs a chat on the process its session names',
74
+ Effect.fnUntraced(
75
+ function* (_) {
76
+ const chat = yield* makeChat(ECHO_PROCESS_KEY);
77
+ const session = yield* AgentService.getSession(chat);
78
+ yield* session.submitPrompt('hello');
79
+ const reply = yield* waitForMessage(session.feed, (message) => message.sender.role === 'assistant', {
80
+ timeout: 5_000,
81
+ });
82
+ expect(Message.extractText(reply)).toBe('echo: hello');
83
+ expect(yield* runningKeys).toEqual([ECHO_PROCESS_KEY]);
84
+ },
85
+ Effect.provide(TestLayer),
86
+ TestHelpers.provideTestContext,
87
+ ),
88
+ );
89
+
90
+ it.effect(
91
+ "runs a chat that names no process, or one nothing contributed, on the assistant's",
92
+ Effect.fnUntraced(
93
+ function* (_) {
94
+ yield* AgentService.getSession(yield* makeChat());
95
+ yield* AgentService.getSession(yield* makeChat('com.example.process.uninstalled'));
96
+ expect(yield* runningKeys).toEqual([AGENT_PROCESS_KEY, AGENT_PROCESS_KEY]);
97
+ },
98
+ Effect.provide(TestLayer),
99
+ TestHelpers.provideTestContext,
100
+ ),
101
+ );
102
+
103
+ it.effect(
104
+ 'moves a chat to the process its session names when that changes',
105
+ Effect.fnUntraced(
106
+ function* (_) {
107
+ const chat = yield* makeChat();
108
+ yield* AgentService.getSession(chat);
109
+ Obj.update(chat, (chat) => {
110
+ chat.session = { ...chat.session, process: ECHO_PROCESS_KEY };
111
+ });
112
+ yield* AgentService.getSession(chat);
113
+ const manager = yield* Process.ManagerService;
114
+ const live = (yield* manager.handles()).filter((process) => process.status.state !== Process.State.TERMINATED);
115
+ expect(live.map((process) => process.key)).toEqual([ECHO_PROCESS_KEY]);
116
+ },
117
+ Effect.provide(TestLayer),
118
+ TestHelpers.provideTestContext,
119
+ ),
120
+ );
121
+
122
+ it.effect(
123
+ 'refuses to run a contributed process on edge',
124
+ Effect.fnUntraced(
125
+ function* (_) {
126
+ const chat = yield* makeChat(ECHO_PROCESS_KEY);
127
+ const exit = yield* AgentService.getSession(chat, { location: 'edge' }).pipe(Effect.exit);
128
+ expect(Exit.isFailure(exit)).toBe(true);
129
+ expect(yield* runningKeys).toEqual([]);
130
+ },
131
+ Effect.provide(TestLayer),
132
+ TestHelpers.provideTestContext,
133
+ ),
134
+ );
135
+ });
@@ -11,8 +11,11 @@ import { expect } from 'vitest';
11
11
 
12
12
  import { ScriptedLanguageModel } from '@dxos/ai/testing';
13
13
  import { Alarm, SessionStore, isConsumed, isQueued } from '@dxos/assistant';
14
+ import { ProcessManager } from '@dxos/compute-runtime';
15
+ import * as ComputeAgentService from '@dxos/compute/AgentService';
14
16
  import * as Operation from '@dxos/compute/Operation';
15
17
  import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
18
+ import * as Process from '@dxos/compute/Process';
16
19
  import * as Skill from '@dxos/compute/Skill';
17
20
  import { type Database, Feed, Filter, Obj } from '@dxos/echo';
18
21
  import * as RuntimeProvider from '@dxos/effect/RuntimeProvider';
@@ -21,6 +24,7 @@ import { DXN, EntityId } from '@dxos/keys';
21
24
  import { Message } from '@dxos/types';
22
25
 
23
26
  import { AssistantTestLayer } from '../testing/index.ts';
27
+ import { AGENT_PROCESS_KEY } from './agent-process.ts';
24
28
  import * as AgentService from './AgentService.ts';
25
29
 
26
30
  const { text, toolCall, scriptedAiService } = ScriptedLanguageModel;
@@ -139,6 +143,41 @@ const readFeed = (feed: Feed.Feed) =>
139
143
  return { items, messages, ...pending };
140
144
  });
141
145
 
146
+ describe('AgentProcess residency (scripted)', () => {
147
+ it.effect(
148
+ 'a resident process stays idle after its turn and takes the next prompt itself',
149
+ Effect.fnUntraced(
150
+ function* (_) {
151
+ const processManager = yield* ProcessManager.ProcessManagerService;
152
+ const session = yield* AgentService.createSession();
153
+ const target = Obj.getURI(session.chat);
154
+
155
+ yield* session.submitPrompt('first');
156
+ yield* session.waitForCompletion();
157
+ const [handle] = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
158
+ expect(handle.status.state).toBe(Process.State.IDLE);
159
+
160
+ // The same session, the same process: no respawn (and no history replay) for a follow-up.
161
+ const followUp = yield* ComputeAgentService.getSession(session.chat);
162
+ yield* followUp.submitPrompt('second');
163
+ yield* followUp.waitForCompletion();
164
+ const processes = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
165
+ expect(processes.map((process) => String(process.pid))).toEqual([String(handle.pid)]);
166
+ expect(new Set(promptTexts((yield* readFeed(session.feed)).messages))).toEqual(new Set(['first', 'second']));
167
+ },
168
+ Effect.provide(
169
+ AssistantTestLayer({
170
+ types: [Alarm.Alarm],
171
+ agent: { resident: true },
172
+ aiService: scriptedAiService(Array.from({ length: 4 }, () => ({ parts: [text('Acknowledged.')] }))),
173
+ }),
174
+ ),
175
+ TestHelpers.provideTestContext,
176
+ ),
177
+ { timeout: 30_000 },
178
+ );
179
+ });
180
+
142
181
  describe('AgentProcess input queue (scripted)', () => {
143
182
  it.effect(
144
183
  'a submitted prompt is queued on the feed, then acked by the turn that consumes it',
@@ -7,7 +7,13 @@ import { describe, it } from 'vitest';
7
7
  import * as Process from '@dxos/compute/Process';
8
8
  import { type ContentBlock } from '@dxos/types';
9
9
 
10
- import { type ToolResultEvent, dropReportedToolResults, toolResultPrompt, wakeUpPrompt } from './agent-process.ts';
10
+ import {
11
+ type ToolResultEvent,
12
+ dropReportedToolResults,
13
+ isToolResultHandled,
14
+ toolResultPrompt,
15
+ wakeUpPrompt,
16
+ } from './agent-process.ts';
11
17
 
12
18
  // Recovering a tool result across a reload was, until now, exercised only when a race inside
13
19
  // `AgentService.test.ts`'s `recovers queued tool results after reload` happened to land on it, and no
@@ -55,6 +61,22 @@ describe('dropReportedToolResults', () => {
55
61
  });
56
62
  });
57
63
 
64
+ // A tool child that can no longer be reattached is answered with an error unless its result is already
65
+ // accounted for; otherwise the call would stay pending and the agent could never complete.
66
+ describe('isToolResultHandled', () => {
67
+ it('is handled when the result is queued for the next turn', ({ expect }) => {
68
+ expect(isToolResultHandled([toolResult('1')], Process.ID.make('1'), () => false)).toBe(true);
69
+ });
70
+
71
+ it('is handled when the result already reached the agent', ({ expect }) => {
72
+ expect(isToolResultHandled([], Process.ID.make('1'), (pid) => pid === Process.ID.make('1'))).toBe(true);
73
+ });
74
+
75
+ it('is not handled when the result was neither queued nor reported', ({ expect }) => {
76
+ expect(isToolResultHandled([toolResult('2')], Process.ID.make('1'), () => false)).toBe(false);
77
+ });
78
+ });
79
+
58
80
  describe('toolResultPrompt', () => {
59
81
  it('redelivers a recovered tool result as a synthetic <result> block', ({ expect }) => {
60
82
  const [block] = toolResultPrompt(toolResult('9', 'listed on the NASDAQ'));
@@ -0,0 +1,182 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { beforeEach, describe, expect, it } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import { ScriptedLanguageModel } from '@dxos/ai/testing';
10
+ import { ProcessManager } from '@dxos/compute-runtime';
11
+ import * as Operation from '@dxos/compute/Operation';
12
+ import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
13
+ import * as Process from '@dxos/compute/Process';
14
+ import * as Skill from '@dxos/compute/Skill';
15
+ import { Feed, Obj, Ref } from '@dxos/echo';
16
+ import { TestHelpers } from '@dxos/effect/testing';
17
+ import { DXN, EntityId } from '@dxos/keys';
18
+ import { Message } from '@dxos/types';
19
+
20
+ import { AssistantTestLayer } from '../testing/index.ts';
21
+ import { AGENT_PROCESS_KEY } from './agent-process.ts';
22
+ import * as AgentService from './AgentService.ts';
23
+
24
+ EntityId.dangerouslyDisableRandomness();
25
+
26
+ const InlineHook = Operation.make({
27
+ meta: { key: DXN.make('com.example.operation.inlineHook'), name: 'Inline hook' },
28
+ input: Schema.Struct({}),
29
+ output: Schema.Void,
30
+ });
31
+
32
+ const BackgroundHook = Operation.make({
33
+ meta: { key: DXN.make('com.example.operation.backgroundHook'), name: 'Background hook' },
34
+ input: Schema.Struct({}),
35
+ output: Schema.Void,
36
+ });
37
+
38
+ const QuickHook = Operation.make({
39
+ meta: { key: DXN.make('com.example.operation.quickHook'), name: 'Quick hook' },
40
+ input: Schema.Struct({}),
41
+ output: Schema.Void,
42
+ });
43
+
44
+ const FailingHook = Operation.make({
45
+ meta: { key: DXN.make('com.example.operation.failingHook'), name: 'Failing hook' },
46
+ input: Schema.Struct({}),
47
+ output: Schema.Void,
48
+ });
49
+
50
+ /** Hook invocations in the order they started and finished, and the gate the background hook holds on. */
51
+ type HookLog = { events: string[]; release: () => void; released: Promise<void> };
52
+
53
+ const makeHookLog = (): HookLog => {
54
+ let release = () => {};
55
+ const released = new Promise<void>((resolve) => {
56
+ release = resolve;
57
+ });
58
+ return { events: [], release, released };
59
+ };
60
+
61
+ let hookLog = makeHookLog();
62
+
63
+ const handlers = OperationHandlerSet.make(
64
+ InlineHook.pipe(Operation.withHandler(() => Effect.sync(() => void hookLog.events.push('inline')))),
65
+ BackgroundHook.pipe(
66
+ Operation.withHandler(() =>
67
+ Effect.gen(function* () {
68
+ hookLog.events.push('background:start');
69
+ yield* Effect.promise(() => hookLog.released);
70
+ hookLog.events.push('background:end');
71
+ }),
72
+ ),
73
+ ),
74
+ FailingHook.pipe(Operation.withHandler(() => Effect.die(new Error('hook failed')))),
75
+ QuickHook.pipe(Operation.withHandler(() => Effect.sync(() => void hookLog.events.push('quick')))),
76
+ );
77
+
78
+ const hook = (operation: Operation.Definition.Any, async: boolean): Skill.Hook => ({
79
+ spec: { _tag: 'end-request' },
80
+ async,
81
+ function: Ref.make(Operation.serialize(operation)),
82
+ });
83
+
84
+ const HookSkill = Skill.make({
85
+ key: 'com.example.skill.hooks',
86
+ name: 'Hooks',
87
+ hooks: [hook(InlineHook, false), hook(BackgroundHook, true)],
88
+ });
89
+
90
+ const FailingHookSkill = Skill.make({
91
+ key: 'com.example.skill.failingHooks',
92
+ name: 'Failing hooks',
93
+ hooks: [hook(FailingHook, true)],
94
+ });
95
+
96
+ // Finishes at once, so its exit can race the agent registering it.
97
+ const QuickHookSkill = Skill.make({
98
+ key: 'com.example.skill.quickHooks',
99
+ name: 'Quick hooks',
100
+ hooks: [hook(QuickHook, true)],
101
+ });
102
+
103
+ const TestLayer = AssistantTestLayer({
104
+ operationHandlers: handlers,
105
+ types: [Skill.Skill, Feed.Feed, Message.Message],
106
+ skills: [HookSkill, FailingHookSkill, QuickHookSkill],
107
+ aiService: ScriptedLanguageModel.scriptedAiService(() => ({ parts: [ScriptedLanguageModel.text('Done.')] })),
108
+ });
109
+
110
+ /** The agent process serving the session's chat. */
111
+ const agentProcess = Effect.fnUntraced(function* (session: { chat: Obj.Unknown }) {
112
+ const processManager = yield* ProcessManager.ProcessManagerService;
113
+ const [handle] = yield* processManager.list({ target: Obj.getURI(session.chat), key: AGENT_PROCESS_KEY });
114
+ return handle;
115
+ });
116
+
117
+ describe('end-request skill hooks', () => {
118
+ beforeEach(() => {
119
+ hookLog = makeHookLog();
120
+ });
121
+
122
+ it.effect(
123
+ 'a background hook does not hold up the request, and the process awaits it before finishing',
124
+ Effect.fnUntraced(
125
+ function* (_) {
126
+ const session = yield* AgentService.createSession({ skills: [HookSkill] });
127
+ yield* session.submitPrompt('Hello.');
128
+ yield* session.waitForCompletion();
129
+
130
+ // The request settled while the background hook is still blocked; the inline hook ran first.
131
+ expect(hookLog.events).toEqual(['inline', 'background:start']);
132
+ const handle = yield* agentProcess(session);
133
+ expect(handle.status.state).not.toBe(Process.State.SUCCEEDED);
134
+
135
+ hookLog.release();
136
+ yield* Effect.promise(async () => {
137
+ await expect.poll(() => handle.status.state, { timeout: 5_000 }).toBe(Process.State.SUCCEEDED);
138
+ });
139
+ expect(hookLog.events).toEqual(['inline', 'background:start', 'background:end']);
140
+ },
141
+ Effect.provide(TestLayer),
142
+ TestHelpers.provideTestContext,
143
+ ),
144
+ );
145
+
146
+ it.effect(
147
+ 'a background hook that finishes at once still lets the process finish',
148
+ Effect.fnUntraced(
149
+ function* (_) {
150
+ const session = yield* AgentService.createSession({ skills: [QuickHookSkill] });
151
+ yield* session.submitPrompt('Hello.');
152
+ yield* session.waitForCompletion();
153
+
154
+ const handle = yield* agentProcess(session);
155
+ yield* Effect.promise(async () => {
156
+ await expect.poll(() => handle.status.state, { timeout: 5_000 }).toBe(Process.State.SUCCEEDED);
157
+ });
158
+ expect(hookLog.events).toEqual(['quick']);
159
+ },
160
+ Effect.provide(TestLayer),
161
+ TestHelpers.provideTestContext,
162
+ ),
163
+ );
164
+
165
+ it.effect(
166
+ 'a failing background hook still lets the process finish',
167
+ Effect.fnUntraced(
168
+ function* (_) {
169
+ const session = yield* AgentService.createSession({ skills: [FailingHookSkill] });
170
+ yield* session.submitPrompt('Hello.');
171
+ yield* session.waitForCompletion();
172
+
173
+ const handle = yield* agentProcess(session);
174
+ yield* Effect.promise(async () => {
175
+ await expect.poll(() => handle.status.state, { timeout: 5_000 }).toBe(Process.State.SUCCEEDED);
176
+ });
177
+ },
178
+ Effect.provide(TestLayer),
179
+ TestHelpers.provideTestContext,
180
+ ),
181
+ );
182
+ });
@@ -18,6 +18,7 @@ import * as Chat from '@dxos/assistant/Chat';
18
18
  import {
19
19
  FeedTraceSink,
20
20
  ProcessManager,
21
+ ProcessOperationInvoker,
21
22
  RemoteProcessManager,
22
23
  RemoteTraceMonitor,
23
24
  TriggerDispatcher,
@@ -101,7 +102,7 @@ export type AssistantTestServices =
101
102
  | Operation.Service
102
103
  | ProcessManager.Service
103
104
  | RemoteProcessManager.Service
104
- | ProcessManager.ProcessOperationInvoker.Service
105
+ | ProcessOperationInvoker.Service
105
106
  | Process.ManagerService
106
107
  | AtomRegistry.AtomRegistry
107
108
  | OperationHandlerSet.OperationHandlerProvider
@@ -139,7 +140,7 @@ export const AssistantTestLayer = (
139
140
  return Layer.empty.pipe(
140
141
  // Captures must sit above the layers they read (a provideMerge chain feeds upward).
141
142
  Layer.provideMerge(captureAgentService(agentServiceHolder)),
142
- Layer.provideMerge(ProcessManager.ProcessOperationInvoker.layer),
143
+ Layer.provideMerge(ProcessOperationInvoker.layer),
143
144
  Layer.provideMerge(AgentServiceRuntime.layer(agentOptions)),
144
145
  Layer.provideMerge(UnifiedProcessManager.layer),
145
146
  // A local test stack has no EDGE, so both the manager's remote half and `location: 'edge'` see