@dxos/assistant 0.9.0 → 0.9.1-staging.ee54ba693a

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 (84) hide show
  1. package/dist/lib/neutral/extraction/index.mjs +108 -119
  2. package/dist/lib/neutral/extraction/index.mjs.map +4 -4
  3. package/dist/lib/neutral/index.mjs +395 -234
  4. package/dist/lib/neutral/index.mjs.map +4 -4
  5. package/dist/lib/neutral/meta.json +1 -1
  6. package/dist/types/src/extraction/extraction-llm-function/index.d.ts +2 -0
  7. package/dist/types/src/extraction/extraction-llm-function/index.d.ts.map +1 -0
  8. package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts +26 -0
  9. package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts.map +1 -0
  10. package/dist/types/src/extraction/index.d.ts +1 -1
  11. package/dist/types/src/extraction/index.d.ts.map +1 -1
  12. package/dist/types/src/extraction/quotes.d.ts +8 -1
  13. package/dist/types/src/extraction/quotes.d.ts.map +1 -1
  14. package/dist/types/src/index.d.ts +1 -1
  15. package/dist/types/src/index.d.ts.map +1 -1
  16. package/dist/types/src/request/AiRequest.d.ts +19 -208
  17. package/dist/types/src/request/AiRequest.d.ts.map +1 -1
  18. package/dist/types/src/request/format.d.ts +1 -1
  19. package/dist/types/src/request/format.d.ts.map +1 -1
  20. package/dist/types/src/session/AiContext.d.ts +27 -105
  21. package/dist/types/src/session/AiContext.d.ts.map +1 -1
  22. package/dist/types/src/session/AiSession.d.ts +11 -29
  23. package/dist/types/src/session/AiSession.d.ts.map +1 -1
  24. package/dist/types/src/session/Harness.d.ts +122 -0
  25. package/dist/types/src/session/Harness.d.ts.map +1 -0
  26. package/dist/types/src/session/SessionLink.d.ts +8 -11
  27. package/dist/types/src/session/SessionLink.d.ts.map +1 -1
  28. package/dist/types/src/session/SessionLoader.d.ts +2 -2
  29. package/dist/types/src/session/SessionLoader.d.ts.map +1 -1
  30. package/dist/types/src/session/SkillHooks.d.ts +28 -0
  31. package/dist/types/src/session/SkillHooks.d.ts.map +1 -0
  32. package/dist/types/src/session/harness-control.d.ts +177 -0
  33. package/dist/types/src/session/harness-control.d.ts.map +1 -0
  34. package/dist/types/src/session/index.d.ts +3 -0
  35. package/dist/types/src/session/index.d.ts.map +1 -1
  36. package/dist/types/src/session/toolkit.d.ts +4 -4
  37. package/dist/types/src/session/toolkit.d.ts.map +1 -1
  38. package/dist/types/src/templates/system.d.ts +4 -6
  39. package/dist/types/src/templates/system.d.ts.map +1 -1
  40. package/dist/types/src/tool-runtime/index.d.ts.map +1 -0
  41. package/dist/types/src/tool-runtime/services.d.ts.map +1 -0
  42. package/dist/types/src/util/artifact.d.ts.map +1 -1
  43. package/dist/types/src/util/diff.d.ts +9 -3
  44. package/dist/types/src/util/diff.d.ts.map +1 -1
  45. package/dist/types/src/util/tracing.d.ts +40 -0
  46. package/dist/types/src/util/tracing.d.ts.map +1 -1
  47. package/dist/types/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +28 -25
  49. package/src/extraction/extraction-llm-function/index.ts +5 -0
  50. package/src/extraction/extraction-llm-function/proper-noun-extraction.ts +78 -0
  51. package/src/extraction/index.ts +1 -1
  52. package/src/extraction/quotes.test.ts +67 -2
  53. package/src/extraction/quotes.ts +47 -13
  54. package/src/index.ts +1 -1
  55. package/src/request/AiRequest.ts +45 -14
  56. package/src/request/format.ts +9 -11
  57. package/src/session/AiContext.test.ts +52 -32
  58. package/src/session/AiContext.ts +73 -95
  59. package/src/session/AiSession.test.ts +4 -4
  60. package/src/session/AiSession.ts +51 -83
  61. package/src/session/Harness.ts +241 -0
  62. package/src/session/SessionLink.ts +8 -8
  63. package/src/session/SessionLoader.ts +4 -4
  64. package/src/session/SkillHooks.ts +71 -0
  65. package/src/session/harness-control.ts +30 -0
  66. package/src/session/index.ts +3 -0
  67. package/src/session/toolkit.ts +8 -8
  68. package/src/templates/instructions/format.tpl +3 -3
  69. package/src/templates/instructions/system.tpl +10 -12
  70. package/src/templates/system.ts +6 -2
  71. package/src/util/artifact.ts +9 -2
  72. package/src/util/diff.test.ts +4 -4
  73. package/src/util/diff.ts +15 -8
  74. package/src/util/tracing.ts +1 -1
  75. package/dist/types/src/extraction/extraction.d.ts +0 -1677
  76. package/dist/types/src/extraction/extraction.d.ts.map +0 -1
  77. package/dist/types/src/functions/index.d.ts.map +0 -1
  78. package/dist/types/src/functions/services.d.ts.map +0 -1
  79. package/src/extraction/extraction-llm-function/transcription-instructions.tpl +0 -28
  80. package/src/extraction/extraction.ts +0 -105
  81. /package/dist/types/src/{functions → tool-runtime}/index.d.ts +0 -0
  82. /package/dist/types/src/{functions → tool-runtime}/services.d.ts +0 -0
  83. /package/src/{functions → tool-runtime}/index.ts +0 -0
  84. /package/src/{functions → tool-runtime}/services.ts +0 -0
@@ -6,7 +6,6 @@
6
6
 
7
7
  import { type Registry as AtomRegistry } from '@effect-atom/atom-react';
8
8
  import * as Array from 'effect/Array';
9
- import * as Context from 'effect/Context';
10
9
  import * as Effect from 'effect/Effect';
11
10
  import * as Either from 'effect/Either';
12
11
  import { pipe } from 'effect/Function';
@@ -15,20 +14,21 @@ import * as Record from 'effect/Record';
15
14
  import * as Runtime from 'effect/Runtime';
16
15
 
17
16
  import { type OpaqueToolkit, type ToolExecutionService, type ToolResolverService } from '@dxos/ai';
18
- import { type Blueprint, McpServer, Operation, Trace } from '@dxos/compute';
17
+ import { McpServer, Operation, type Skill, Trace } from '@dxos/compute';
19
18
  import { Resource } from '@dxos/context';
20
19
  import { Database, Feed, Filter, Obj, Registry } from '@dxos/echo';
21
- import { EffectEx } from '@dxos/effect';
22
20
  import { invariant } from '@dxos/invariant';
23
21
  import { log } from '@dxos/log';
24
22
  import { McpToolkit } from '@dxos/mcp-client';
25
- import { Message, type ContentBlock } from '@dxos/types';
23
+ import { type ContentBlock, Message } from '@dxos/types';
26
24
 
27
- import { ToolExecutionServices } from '../functions';
28
25
  import { AiRequest, type GenerationObserver, formatSystemPrompt } from '../request';
26
+ import { ToolExecutionServices } from '../tool-runtime';
29
27
  import { McpServerError } from '../util';
30
28
  import * as AiContext from './AiContext';
29
+ import * as Harness from './Harness';
31
30
  import { SessionLoader } from './SessionLoader';
31
+ import * as SkillHooks from './SkillHooks';
32
32
  import { createToolkit } from './toolkit';
33
33
 
34
34
  export type RunProps<R = never> = {
@@ -38,14 +38,21 @@ export type RunProps<R = never> = {
38
38
  toolkit?: OpaqueToolkit.OpaqueToolkit<R>;
39
39
 
40
40
  /**
41
- * Space-level MCP servers to connect alongside blueprint-defined ones.
41
+ * Space-level MCP servers to connect alongside skill-defined ones.
42
42
  */
43
43
  mcpServers?: readonly McpServer.McpServer[];
44
+
45
+ /**
46
+ * When false, messages from this request are not appended to the feed or persisted to trace.
47
+ *
48
+ * @default true
49
+ */
50
+ persist?: boolean;
44
51
  };
45
52
 
46
53
  export type Options = {
47
54
  feed: Feed.Feed;
48
- runtime: Runtime.Runtime<Feed.FeedService>;
55
+ runtime: Runtime.Runtime<Database.Service>;
49
56
  /** @effect-atom/atom-react Registry for reactive state. */
50
57
  registry?: AtomRegistry.Registry;
51
58
  };
@@ -62,11 +69,11 @@ const SUMMARY_THRESHOLD = 80_000;
62
69
  */
63
70
  export class Session extends Resource {
64
71
  /**
65
- * Blueprints and objects bound to the session.
72
+ * Skills and objects bound to the session.
66
73
  */
67
74
  private readonly _binder: AiContext.Binder;
68
75
  private readonly _feed: Feed.Feed;
69
- private readonly _runtime: Runtime.Runtime<Feed.FeedService>;
76
+ private readonly _runtime: Runtime.Runtime<Database.Service>;
70
77
  private readonly _sessionLoader = new SessionLoader();
71
78
 
72
79
  public constructor(options: Options) {
@@ -107,7 +114,7 @@ export class Session extends Resource {
107
114
  ToolExecutionService | ToolResolverService
108
115
  > {
109
116
  return Effect.gen(this, function* () {
110
- const toolkit = yield* createToolkit({ blueprints: this.context.getBlueprints() });
117
+ const toolkit = yield* createToolkit({ skills: this.context.getSkills() });
111
118
  return toolkit.toolkit.tools;
112
119
  }).pipe(Effect.orDie);
113
120
  }
@@ -133,52 +140,66 @@ export class Session extends Resource {
133
140
  ): Effect.Effect<Message.Message[], AiRequest.RunError, AiRequest.RunRequirements | R> {
134
141
  return Effect.gen(this, function* () {
135
142
  const history = yield* Effect.promise(() => this.getHistory());
136
- const blueprints = this.context.getBlueprints();
143
+ const skills = this.context.getSkills();
137
144
  const objects = this.context.getObjects();
138
145
 
139
146
  log('run', {
140
147
  history: history.length,
141
- blueprints: blueprints.length,
148
+ skills: skills.length,
142
149
  objects: objects.length,
143
150
  });
144
151
 
145
152
  const request = new AiRequest.Request({
146
153
  summarizationThreshold: SUMMARY_THRESHOLD,
147
154
  observer: params.observer,
155
+ persist: params.persist,
148
156
  onOutput: (message) =>
149
157
  Effect.promise(() => Runtime.runPromise(this._runtime)(Feed.append(this._feed, [message]))),
150
158
  });
151
159
 
152
160
  yield* request.begin({
153
161
  history,
154
- blueprints,
162
+ skills,
155
163
  objects,
156
164
  prompt: params.prompt,
157
165
  system: params.system,
158
166
  });
159
167
 
160
- // Turn loop: recompute toolkit and system prompt between turns to pick up dynamically enabled blueprints.
168
+ // Fire begin-request hooks declared by the bound skills. These run in the agent's turn
169
+ // fiber (Tier A only), so they cannot reach the live host (Tier B) — that is the end hook's job.
170
+ yield* SkillHooks.runHooks({
171
+ skills,
172
+ phase: 'begin-request',
173
+ invoke: (operation, input) => Operation.invoke(operation, input).pipe(Effect.asVoid, Effect.orDie),
174
+ });
175
+
176
+ // Turn loop: recompute toolkit and system prompt between turns to pick up dynamically enabled skills.
161
177
  do {
162
178
  yield* Effect.promise(() => this.context.sync());
163
- const currentBlueprints = this.context.getBlueprints();
164
- const mcps = yield* connectMcpServers(currentBlueprints, params.mcpServers);
179
+ const currentSkills = this.context.getSkills();
180
+ const mcps = yield* connectMcpServers(currentSkills, params.mcpServers);
165
181
  const toolkit = yield* createToolkit({
166
182
  toolkit: params.toolkit,
167
- blueprints: currentBlueprints,
183
+ skills: currentSkills,
168
184
  opaqueToolkits: mcps,
169
185
  });
170
186
 
171
187
  log('toolkit', { tools: Record.keys(toolkit.toolkit.tools) });
172
188
  const system = yield* formatSystemPrompt({
173
189
  system: params.system,
174
- blueprints: currentBlueprints,
190
+ skills: currentSkills,
175
191
  objects: this.context.getObjects(),
176
192
  }).pipe(Effect.orDie);
177
193
 
178
- const { done } = yield* request.runAgentTurn({ system, toolkit });
194
+ const { done, finishReason } = yield* request.runAgentTurn({ system, toolkit });
179
195
  if (done) {
180
196
  break;
181
197
  }
198
+ // A paused server-tool turn (e.g. Anthropic `pause_turn`) resumes with another request and
199
+ // no local tool execution; the trailing server tool call must be left intact for the provider.
200
+ if (finishReason === 'pause') {
201
+ continue;
202
+ }
182
203
 
183
204
  yield* request.runTools({ toolkit });
184
205
  } while (true);
@@ -193,10 +214,13 @@ export class Session extends Resource {
193
214
  }).pipe(
194
215
  Effect.provide(
195
216
  Layer.mergeAll(
196
- Layer.succeed(AiContext.Service, {
197
- binder: this.context,
198
- }),
199
- Layer.succeed(Service, this),
217
+ // Tier A only: the agent's own turn fiber has no `ProcessManager.Service` in scope, so
218
+ // Tier B (setAlarm/enqueueMessage) is reachable only by child operations through the
219
+ // process-affinity HarnessService LayerSpec — not from here.
220
+ Layer.succeed(
221
+ Harness.HarnessService,
222
+ Harness.fromBinder({ feed: this._feed, runtime: this._runtime, binder: this.context }),
223
+ ),
200
224
  Operation.withInvocationOptions({ conversation: Obj.getURI(this._feed) }),
201
225
  ),
202
226
  ),
@@ -205,68 +229,12 @@ export class Session extends Resource {
205
229
  }
206
230
  }
207
231
 
208
- /**
209
- * Gives access to the ai session.
210
- */
211
- export class Service extends Context.Tag('@dxos/assistant/AiSessionService')<Service, Session>() {
212
- /**
213
- * Create a new session layer from options.
214
- */
215
- static layer = (options: Options): Layer.Layer<Service | AiContext.Service> =>
216
- aiContextFromSession.pipe(
217
- Layer.provideMerge(
218
- Layer.scoped(
219
- Service,
220
- Effect.gen(function* () {
221
- const session = yield* EffectEx.acquireReleaseResource(() => new Session(options));
222
- return session;
223
- }),
224
- ),
225
- ),
226
- );
227
-
228
- /**
229
- * Create a new session with a new feed.
230
- */
231
- static layerNewFeed = (
232
- options?: Omit<Options, 'feed' | 'runtime'>,
233
- ): Layer.Layer<Service | AiContext.Service, never, Database.Service | Feed.FeedService> =>
234
- Layer.unwrapScoped(
235
- Effect.gen(function* () {
236
- const feed = yield* Database.add(Feed.make());
237
- const runtime = yield* Effect.runtime<Feed.FeedService>();
238
- return Service.layer({ ...options, feed, runtime });
239
- }),
240
- );
241
-
242
- /**
243
- * Run a prompt in the current session.
244
- */
245
- static run = <R = never>(
246
- params: RunProps<R>,
247
- ): Effect.Effect<Message.Message[], AiRequest.RunError, AiRequest.RunRequirements | Service | R> =>
248
- Effect.gen(function* () {
249
- const session = yield* Service;
250
- return yield* session.createRequest(params);
251
- });
252
- }
253
-
254
- const aiContextFromSession = Layer.effect(
255
- AiContext.Service,
256
- Effect.gen(function* () {
257
- const session = yield* Service;
258
- return {
259
- binder: session.context,
260
- };
261
- }),
262
- );
263
-
264
232
  const connectMcpServers = (
265
- blueprints: readonly Blueprint.Blueprint[],
233
+ skills: readonly Skill.Skill[],
266
234
  spaceMcpServers: readonly McpServer.McpServer[] = [],
267
235
  ): Effect.Effect<OpaqueToolkit.OpaqueToolkit[], never, Trace.TraceService> => {
268
- const blueprintServers: McpToolkit.McpToolkitOptions[] = pipe(
269
- blueprints,
236
+ const skillServers: McpToolkit.McpToolkitOptions[] = pipe(
237
+ skills,
270
238
  Array.flatMap((_) => _.mcpServers ?? []),
271
239
  Array.map(({ url, protocol, apiKey }) => ({ url, protocol, apiKey })),
272
240
  );
@@ -275,7 +243,7 @@ const connectMcpServers = (
275
243
  protocol,
276
244
  apiKey,
277
245
  }));
278
- const allServers = [...blueprintServers, ...spaceServers];
246
+ const allServers = [...skillServers, ...spaceServers];
279
247
 
280
248
  return pipe(
281
249
  allServers,
@@ -0,0 +1,241 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import type * as RpcClient from '@effect/rpc/RpcClient';
8
+ import * as Context from 'effect/Context';
9
+ import * as DateTime from 'effect/DateTime';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as Option from 'effect/Option';
13
+ import type * as Runtime from 'effect/Runtime';
14
+ import type * as Scope from 'effect/Scope';
15
+
16
+ import { LayerSpec, Process, ServiceNotAvailableError } from '@dxos/compute';
17
+ import { ProcessManager } from '@dxos/compute-runtime';
18
+ import { Annotation, Database, EID, Feed, Filter, Obj, type URI } from '@dxos/echo';
19
+ import { EffectEx } from '@dxos/effect';
20
+ import { BaseError } from '@dxos/errors';
21
+ import { type ContentBlock, Message } from '@dxos/types';
22
+
23
+ import * as AiContext from './AiContext';
24
+ import { type HarnessControlRpcs } from './harness-control';
25
+ import { SessionLoader } from './SessionLoader';
26
+
27
+ export interface Service {
28
+ /** The conversation {@link AiContext.Binder} (Tier A). */
29
+ binder: Effect.Effect<AiContext.Binder, NotSupportedError>;
30
+ /** The conversation message history (Tier A). */
31
+ history: Effect.Effect<Message.Message[], NotSupportedError>;
32
+ /** Objects bound to the conversation that match the filter (Tier A). */
33
+ queryContext<T extends Obj.Unknown>(filter: Filter.Filter<T>): Effect.Effect<T[], NotSupportedError>;
34
+ /** Enqueue a message into the owning host's input queue (Tier B). */
35
+ enqueueMessage(options: EnqueueMessageOptions): Effect.Effect<void, NotSupportedError>;
36
+ /** Schedule a self-wake on the owning host (Tier B). */
37
+ setAlarm(options: SetAlarmOptions): Effect.Effect<void, NotSupportedError>;
38
+ }
39
+
40
+ /**
41
+ * Provides access to the controlling harness for tools and operations that are executed by the agent.
42
+ *
43
+ * Replaces AiContextService and AiSessionService.
44
+ */
45
+ export class HarnessService extends Context.Tag('@dxos/assistant/HarnessService')<HarnessService, Service>() {}
46
+
47
+ /**
48
+ * Acess current context binder.
49
+ */
50
+ export const binder: Effect.Effect<AiContext.Binder, NotSupportedError, HarnessService> = Effect.flatMap(
51
+ HarnessService,
52
+ (service) => service.binder,
53
+ );
54
+
55
+ /**
56
+ * Query the context using a filter.
57
+ */
58
+ export const queryContext = <T extends Obj.Unknown>(
59
+ filter: Filter.Filter<T>,
60
+ ): Effect.Effect<T[], NotSupportedError, HarnessService> =>
61
+ Effect.flatMap(HarnessService, (service) => service.queryContext(filter));
62
+
63
+ interface EnqueueMessageOptions {
64
+ content: ContentBlock.Any[];
65
+
66
+ // TODO(dmaretskyi): Order: 'first' | 'last'
67
+ }
68
+
69
+ /**
70
+ * Query the session history.
71
+ */
72
+ export const history: Effect.Effect<Message.Message[], NotSupportedError, HarnessService> = Effect.flatMap(
73
+ HarnessService,
74
+ (service) => service.history,
75
+ );
76
+
77
+ /**
78
+ * Enqueue a message to the harness.
79
+ */
80
+ export const enqueueMessage = (
81
+ options: EnqueueMessageOptions,
82
+ ): Effect.Effect<void, NotSupportedError, HarnessService> =>
83
+ Effect.flatMap(HarnessService, (service) => service.enqueueMessage(options));
84
+
85
+ interface SetAlarmOptions {
86
+ at: DateTime.DateTime;
87
+ /**
88
+ * Message to send when the alarm fires.
89
+ */
90
+ message: string | null;
91
+ }
92
+
93
+ export const setAlarm = (options: SetAlarmOptions): Effect.Effect<void, NotSupportedError, HarnessService> =>
94
+ Effect.flatMap(HarnessService, (service) => service.setAlarm(options));
95
+
96
+ /**
97
+ * Binds skills and/or objects to the conversation context.
98
+ */
99
+ export const bindContext = ({
100
+ skills,
101
+ objects,
102
+ }: AiContext.BindingProps): Effect.Effect<void, NotSupportedError, HarnessService> =>
103
+ Effect.gen(function* () {
104
+ const sessionBinder = yield* binder;
105
+ yield* Effect.promise(() => sessionBinder.bind({ skills, objects }));
106
+ });
107
+
108
+ export class NotSupportedError extends BaseError.extend('NotSupportedError', 'Operation not supported') {}
109
+
110
+ /**
111
+ * Process-affinity {@link LayerSpec} that materialises {@link HarnessService} from the resolution
112
+ * context's `conversation` DXN. Merges the former `AiContext.Service` (Tier A) with live host
113
+ * dispatch (Tier B).
114
+ */
115
+ export const layerSpec: LayerSpec.LayerSpec = LayerSpec.make(
116
+ {
117
+ affinity: 'process',
118
+ requires: [Database.Service, ProcessManager.Service],
119
+ provides: [HarnessService],
120
+ },
121
+ (context) =>
122
+ Layer.scoped(
123
+ HarnessService,
124
+ Effect.gen(function* () {
125
+ if (!context.conversation) {
126
+ // LayerSpec.make requires a never-failing layer (error channel = never), so typed failure
127
+ // is not possible here. Die with ServiceNotAvailableError to signal a programming error
128
+ // (missing 'conversation' in spawn environment) that callers cannot recover from.
129
+ return yield* Effect.die(
130
+ new ServiceNotAvailableError(HarnessService.key, {
131
+ message: `Service not available: ${HarnessService.key} — process spawn is missing 'conversation' in environment (set via Operation.withInvocationOptions or ProcessManager.spawn environment)`,
132
+ }),
133
+ );
134
+ }
135
+ const conversation = context.conversation;
136
+ const processManager = yield* ProcessManager.Service;
137
+ const runtime = yield* Effect.runtime<Database.Service>();
138
+ return yield* make({ conversation, processManager, runtime });
139
+ }),
140
+ ),
141
+ );
142
+
143
+ interface MakeOptions {
144
+ conversation: URI.URI;
145
+ processManager: Context.Tag.Service<ProcessManager.Service>;
146
+ runtime: Runtime.Runtime<Database.Service>;
147
+ }
148
+
149
+ /**
150
+ * Builds the {@link Service} for a conversation-scoped resolution context. Tier A resolves the
151
+ * conversation feed / {@link AiContext.Binder} once; Tier B looks up the owning host process lazily per call
152
+ * and dispatches over its `Handle.rpc` control surface (raising {@link NotSupportedError} when no
153
+ * live host owns the conversation).
154
+ */
155
+ export const make = ({
156
+ conversation,
157
+ processManager,
158
+ runtime,
159
+ }: MakeOptions): Effect.Effect<Service, never, Scope.Scope> =>
160
+ Effect.gen(function* () {
161
+ const feed = yield* Database.resolve(EID.parse(conversation), Feed.Feed).pipe(
162
+ Effect.provide(runtime),
163
+ Effect.orDie,
164
+ );
165
+ const boundBinder = yield* EffectEx.acquireReleaseResource(() => new AiContext.Binder({ feed, runtime }));
166
+ return makeService({
167
+ feed,
168
+ runtime,
169
+ binder: boundBinder,
170
+ owningHost: lookupOwningHost(processManager, conversation),
171
+ });
172
+ });
173
+
174
+ interface FromBinderOptions {
175
+ feed: Feed.Feed;
176
+ runtime: Runtime.Runtime<Database.Service>;
177
+ binder: AiContext.Binder;
178
+ }
179
+
180
+ /**
181
+ * Builds the {@link Service} from a {@link AiContext.Binder} already opened for the conversation (e.g. the
182
+ * agent's own turn fiber, which has no `ProcessManager.Service` in scope). Tier A is fully served;
183
+ * Tier B raises {@link NotSupportedError} since the live-host control surface is not reachable here.
184
+ */
185
+ export const fromBinder = ({ feed, runtime, binder }: FromBinderOptions): Service =>
186
+ makeService({ feed, runtime, binder, owningHost: Effect.fail(new NotSupportedError()) });
187
+
188
+ interface MakeServiceOptions {
189
+ feed: Feed.Feed;
190
+ runtime: Runtime.Runtime<Database.Service>;
191
+ binder: AiContext.Binder;
192
+ owningHost: Effect.Effect<RpcClient.RpcClient<HarnessControlRpcs>, NotSupportedError>;
193
+ }
194
+
195
+ const makeService = ({ feed, runtime, binder, owningHost }: MakeServiceOptions): Service => ({
196
+ binder: Effect.succeed(binder),
197
+ history: Effect.gen(function* () {
198
+ const messages = yield* Feed.query(feed, Filter.type(Message.Message)).run;
199
+ return yield* new SessionLoader().reifyHistory(feed, messages);
200
+ }).pipe(Effect.provide(runtime)),
201
+ queryContext: <T extends Obj.Unknown>(filter: Filter.Filter<T>) =>
202
+ Effect.sync(() => {
203
+ const match = Filter.toPredicate(filter);
204
+ return binder.getObjects().filter(match);
205
+ }),
206
+ enqueueMessage: ({ content }) => owningHost.pipe(Effect.flatMap((rpc) => rpc.enqueueMessage({ content }))),
207
+ setAlarm: ({ at, message }) =>
208
+ owningHost.pipe(Effect.flatMap((rpc) => rpc.setAlarm({ at: DateTime.toUtc(at), message }))),
209
+ });
210
+
211
+ /**
212
+ * Resolves the live host owning `conversation` per call (so a process replacement — e.g. a model
213
+ * switch — is never captured stale) and exposes its `HarnessControl` RPC client.
214
+ */
215
+ const lookupOwningHost = (
216
+ processManager: Context.Tag.Service<ProcessManager.Service>,
217
+ conversation: URI.URI,
218
+ ): Effect.Effect<RpcClient.RpcClient<HarnessControlRpcs>, NotSupportedError> =>
219
+ Effect.gen(function* () {
220
+ const processes = yield* processManager.list({ target: conversation });
221
+ const host = processes.find(
222
+ (process) =>
223
+ !isTerminalProcess(process.status.state) &&
224
+ Option.getOrElse(
225
+ Annotation.getDictionary(process.params.annotations, Process.HarnessHostAnnotation),
226
+ () => false,
227
+ ),
228
+ );
229
+ if (!host) {
230
+ return yield* Effect.fail(new NotSupportedError());
231
+ }
232
+ // The host is discovered by annotation, but the HarnessControl RPC contract is fixed at the
233
+ // spawn site (agent-process declares it as its `rpcs`); narrow the variance-erased
234
+ // `RpcClient<any>` to that contract so the dispatch below is typed. No typed path exists here:
235
+ // the host handle carries `RpcClient<any>` (design spec §4.4) and the concrete definition lives
236
+ // in `@dxos/functions-runtime`, which depends on this package — importing it would cycle.
237
+ return host.rpc as unknown as RpcClient.RpcClient<HarnessControlRpcs>;
238
+ });
239
+
240
+ const isTerminalProcess = (state: Process.State): boolean =>
241
+ state === Process.State.SUCCEEDED || state === Process.State.FAILED || state === Process.State.TERMINATED;
@@ -13,11 +13,11 @@ import { DXN, Feed, Obj, Ref, Type } from '@dxos/echo';
13
13
  * When present in a feed, the messages from the referenced feed up to and including
14
14
  * `messageId` are prepended to the conversation history before each turn.
15
15
  */
16
- export const SessionLink = Schema.Struct({
17
- /** Reference to the feed of the source session. */
18
- feedRef: Ref.Ref(Feed.Feed),
19
- /** Messages from the source feed up to and including this message ID are injected. */
20
- messageId: Obj.ID,
21
- }).pipe(Type.makeObject(DXN.make('org.dxos.type.sessionLink', '0.1.0')));
22
-
23
- export type SessionLink = Type.InstanceType<typeof SessionLink>;
16
+ export class SessionLink extends Type.makeObject<SessionLink>(DXN.make('org.dxos.type.sessionLink', '0.1.0'))(
17
+ Schema.Struct({
18
+ /** Reference to the feed of the source session. */
19
+ feedRef: Ref.Ref(Feed.Feed),
20
+ /** Messages from the source feed up to and including this message ID are injected. */
21
+ messageId: Obj.ID,
22
+ }),
23
+ ) {}
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as Effect from 'effect/Effect';
6
6
 
7
- import { Feed, Filter, Obj } from '@dxos/echo';
7
+ import { Database, Feed, Filter, Obj } from '@dxos/echo';
8
8
  import { Message } from '@dxos/types';
9
9
 
10
10
  import * as SessionLink from './SessionLink';
@@ -21,9 +21,9 @@ export class SessionLoader {
21
21
  reifyHistory(
22
22
  feed: Feed.Feed,
23
23
  messages: Message.Message[],
24
- ): Effect.Effect<Message.Message[], never, Feed.FeedService> {
24
+ ): Effect.Effect<Message.Message[], never, Database.Service> {
25
25
  return Effect.gen(function* () {
26
- const links = yield* Feed.runQuery(feed, Filter.type(SessionLink.SessionLink));
26
+ const links = yield* Feed.query(feed, Filter.type(SessionLink.SessionLink)).run;
27
27
  const sessionLinks = links.filter(Obj.instanceOf(SessionLink.SessionLink));
28
28
 
29
29
  const link = sessionLinks[0];
@@ -36,7 +36,7 @@ export class SessionLoader {
36
36
  return messages;
37
37
  }
38
38
 
39
- const sourceMessages = yield* Feed.runQuery(sourceFeed, Filter.type(Message.Message));
39
+ const sourceMessages = yield* Feed.query(sourceFeed, Filter.type(Message.Message)).run;
40
40
  const filtered = sourceMessages.filter(Obj.instanceOf(Message.Message));
41
41
 
42
42
  // Sort by creation timestamp so history is in chronological order.
@@ -0,0 +1,71 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Effect from 'effect/Effect';
8
+
9
+ import { Operation, Skill } from '@dxos/compute';
10
+ import { Database, Obj } from '@dxos/echo';
11
+ import { invariant } from '@dxos/invariant';
12
+ import { log } from '@dxos/log';
13
+
14
+ /**
15
+ * Tag of the lifecycle point a hook fires at, matching {@link Skill.HookSpec}.
16
+ */
17
+ export type HookPhase = 'begin-request' | 'end-request';
18
+
19
+ /**
20
+ * Invokes a resolved operation with the hook's templated input. Begin and end hooks fire in
21
+ * different contexts (the agent's turn fiber vs. a child process with Tier B reachable), so the
22
+ * invocation strategy is supplied by the caller rather than fixed here. `R` carries the invoker's
23
+ * own requirements (e.g. `Operation.Service`) up into {@link runHooks}.
24
+ */
25
+ export type HookInvoker<R> = (
26
+ operation: Operation.Definition.Any,
27
+ input: Record<string, unknown>,
28
+ ) => Effect.Effect<void, never, R>;
29
+
30
+ /**
31
+ * Runs every {@link Skill.Hook} on `skills` whose spec matches `phase`. Each hook resolves its
32
+ * `function` ref to a {@link Operation.PersistentOperation}, deserializes it to a definition, and
33
+ * invokes it with the templated `input` via `invoke`. Hooks without a function ref are skipped.
34
+ *
35
+ * Hook failures are isolated: a throwing hook logs and is swallowed so one skill's hook cannot
36
+ * abort the request lifecycle (or the sibling hooks).
37
+ */
38
+ export const runHooks = <R>({
39
+ skills,
40
+ phase,
41
+ invoke,
42
+ }: {
43
+ skills: readonly Skill.Skill[];
44
+ phase: HookPhase;
45
+ invoke: HookInvoker<R>;
46
+ }): Effect.Effect<void, never, Database.Service | R> =>
47
+ Effect.gen(function* () {
48
+ for (const skill of skills) {
49
+ for (const hook of skill.hooks ?? []) {
50
+ if (hook.spec._tag !== phase) {
51
+ continue;
52
+ }
53
+ if (!hook.function) {
54
+ continue;
55
+ }
56
+
57
+ yield* Effect.gen(function* () {
58
+ const record = yield* Database.load(hook.function!);
59
+ invariant(Obj.instanceOf(Operation.PersistentOperation, record), 'Hook function must be an operation.');
60
+ const operation = Operation.deserialize(record);
61
+ // Hooks carry no event payload (the spec is a bare tag), so the input template is passed
62
+ // through verbatim — the hook's operation reads any conversation state it needs itself.
63
+ yield* invoke(operation, { ...hook.input });
64
+ }).pipe(
65
+ Effect.catchAllCause((cause) =>
66
+ Effect.sync(() => log.warn('skill hook failed', { phase, skill: Skill.getKey(skill), cause })),
67
+ ),
68
+ );
69
+ }
70
+ }
71
+ });
@@ -0,0 +1,30 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Rpc from '@effect/rpc/Rpc';
6
+ import * as RpcGroup from '@effect/rpc/RpcGroup';
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import { ContentBlock } from '@dxos/types';
10
+
11
+ /**
12
+ * Typed control surface on the live host process that owns a conversation's harness.
13
+ *
14
+ * The same group is declared as the agent process's `rpcs` (so its handlers run on the host's
15
+ * server fiber with the live `AlarmManager`/`inputQueue` in scope) and used by {@link HarnessService}
16
+ * Tier B to dispatch over the discovered host's `Handle.rpc` — keeping both ends on one contract.
17
+ */
18
+ export const HarnessControl = RpcGroup.make(
19
+ Rpc.make('setAlarm', {
20
+ payload: Schema.Struct({ at: Schema.DateTimeUtc, message: Schema.NullOr(Schema.String) }),
21
+ success: Schema.Void,
22
+ }),
23
+ Rpc.make('enqueueMessage', {
24
+ payload: Schema.Struct({ content: Schema.Array(ContentBlock.Any) }),
25
+ success: Schema.Void,
26
+ }),
27
+ );
28
+
29
+ /** Union of the RPCs declared in {@link HarnessControl}. */
30
+ export type HarnessControlRpcs = RpcGroup.Rpcs<typeof HarnessControl>;
@@ -6,5 +6,8 @@ export * from './toolkit';
6
6
 
7
7
  export * as AiContext from './AiContext';
8
8
  export * as AiSession from './AiSession';
9
+ export * as Harness from './Harness';
10
+ export * as SkillHooks from './SkillHooks';
11
+ export { HarnessControl, type HarnessControlRpcs } from './harness-control';
9
12
  export { SessionLoader } from './SessionLoader';
10
13
  export * as SessionLink from './SessionLink';