@ixo/oracle-runtime-workers 0.39.0 → 0.40.1

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.
@@ -20,6 +20,28 @@ export interface RepetitionCaps {
20
20
 
21
21
  export const DEFAULT_REPETITION_CAPS: RepetitionCaps = { reads: 5, writes: 1 };
22
22
 
23
+ /**
24
+ * Tools that only find other tools; a model alternating between them never trips the identical-call cap.
25
+ * `load_capability` is not one: it is how a gated tool becomes callable.
26
+ */
27
+ export const DISCOVERY_TOOL_NAMES: ReadonlySet<string> = new Set([
28
+ 'list_capabilities',
29
+ 'list_skills',
30
+ 'search_skills',
31
+ ]);
32
+
33
+ function discoveryCallsInTurn(messages: readonly BaseMessage[]): number {
34
+ let count = 0;
35
+ for (let i = turnStart(messages); i < messages.length; i++) {
36
+ const msg = messages[i];
37
+ if (msg instanceof ToolMessage && DISCOVERY_TOOL_NAMES.has(msg.name ?? ''))
38
+ count += 1;
39
+ }
40
+ return count;
41
+ }
42
+ /** Discovery calls a turn may make, whatever their arguments. */
43
+ export const MAX_DISCOVERY_CALLS = 6;
44
+
23
45
  /**
24
46
  * Parse `TURN_MAX_IDENTICAL_READS` / `TURN_MAX_IDENTICAL_WRITES`; anything
25
47
  * that is not a positive number keeps its default (as `turnLimitsFromEnv`).
@@ -99,6 +121,18 @@ export const createToolRepetitionGuardMiddleware = (
99
121
 
100
122
  return createMiddleware({
101
123
  name: 'ToolRepetitionGuardMiddleware',
124
+ // A model can keep calling a refused tool forever; once the turn spent its searches, it no longer sees them.
125
+ wrapModelCall: (request, handler) => {
126
+ if (
127
+ discoveryCallsInTurn(request.state.messages ?? []) < MAX_DISCOVERY_CALLS
128
+ )
129
+ return handler(request);
130
+ const tools = request.tools.filter(
131
+ (t) =>
132
+ !(typeof t.name === 'string' && DISCOVERY_TOOL_NAMES.has(t.name)),
133
+ );
134
+ return handler({ ...request, tools });
135
+ },
102
136
  wrapToolCall: async (toolCallRequest, handler) => {
103
137
  const { toolCall, state } = toolCallRequest;
104
138
  const toolName = toolCall.name ?? toolCallRequest.tool?.name;
@@ -109,6 +143,26 @@ export const createToolRepetitionGuardMiddleware = (
109
143
  const opening = turnStart(messages);
110
144
  const start = Math.max(opening, messages.length - lookback, 0);
111
145
  const argsById = toolCallArgsById(messages, start);
146
+
147
+ if (DISCOVERY_TOOL_NAMES.has(toolName)) {
148
+ const discovered = discoveryCallsInTurn(messages);
149
+ if (discovered >= MAX_DISCOVERY_CALLS) {
150
+ logger.warn(
151
+ `Repetition guard: ${toolName} refused after ${discovered} discovery calls this turn`,
152
+ );
153
+ return new ToolMessage({
154
+ content: [
155
+ `You have already searched capabilities and skills ${discovered} times in this turn, so \`${toolName}\` was NOT run.`,
156
+ '',
157
+ 'Stop searching. Act with the tools you already have: read their descriptions and call the one that fits the request,',
158
+ 'or tell the user what you could not find and ask them what they want.',
159
+ ].join('\n'),
160
+ tool_call_id: toolCall.id ?? '',
161
+ name: toolName,
162
+ status: 'error',
163
+ });
164
+ }
165
+ }
112
166
  // The turn's calls a mid-turn summary condensed away (oldest first):
113
167
  // only while the lookback still reaches the summary.
114
168
  const openingMessage = messages[opening];
@@ -121,7 +175,13 @@ export const createToolRepetitionGuardMiddleware = (
121
175
  call.name === toolName && canonicalArgsKey(call.args) === argsKey,
122
176
  );
123
177
 
124
- const failedInTurn = (priorError: string): ToolMessage => {
178
+ const failedInTurn = (failure: string): ToolMessage => {
179
+ // A refusal of this guard is not quoted inside the next one, or each repeat nests every earlier one.
180
+ const priorError = /was NOT (called|run) again|was NOT run\b/.test(
181
+ failure,
182
+ )
183
+ ? `This guard already refused it: \`${toolName}\` reached its limit in this turn.`
184
+ : failure;
125
185
  logger.warn(
126
186
  `Repetition guard: short-circuiting duplicate failed call to ${toolName}`,
127
187
  { toolName, priorError },
@@ -1,3 +1,11 @@
1
+ import type {
2
+ CheckClaimDispatch,
3
+ CheckDispatch,
4
+ } from '@ixo/action-interactions';
5
+ import type {
6
+ InteractionCommand,
7
+ InteractionOutcome,
8
+ } from '../action-interactions/store';
1
9
  import type {
2
10
  ChannelAttachment,
3
11
  ChannelControlInput,
@@ -63,6 +71,7 @@ import type { DomainContextProvenance } from '../core/domain-context';
63
71
 
64
72
  /** Bindings every oracle Worker must declare (see the example `wrangler.jsonc`). */
65
73
  export interface OracleWorkerEnv {
74
+ ACTION_INTERACTIONS_ENABLED?: string;
66
75
  TOPIC_DELIVERABLES_ENABLED?: string;
67
76
  ACTION_PROPOSALS_RPC_TESTNET?: string;
68
77
  ACTION_PROPOSALS_RPC_DEVNET?: string;
@@ -782,6 +791,29 @@ export interface StorageStatus {
782
791
  * piped straight to the client.
783
792
  */
784
793
  export interface UserOracleObject extends Rpc.DurableObjectBranded {
794
+ readActionInteractionTask(
795
+ identity: TurnIdentity,
796
+ sessionId: string,
797
+ taskId: string,
798
+ ): Promise<InteractionOutcome<string>>;
799
+ checkDispatch(
800
+ identity: TurnIdentity,
801
+ input: CheckDispatch,
802
+ ): Promise<InteractionOutcome<string>>;
803
+ checkClaimDispatch(
804
+ identity: TurnIdentity,
805
+ input: CheckClaimDispatch,
806
+ ): Promise<InteractionOutcome<string>>;
807
+ actionInteraction(
808
+ identity: TurnIdentity,
809
+ command: InteractionCommand,
810
+ ): Promise<InteractionOutcome<string>>;
811
+ readActionInteraction(
812
+ identity: TurnIdentity,
813
+ sessionId: string,
814
+ id?: string,
815
+ references?: boolean,
816
+ ): Promise<InteractionOutcome<string>>;
785
817
  recordHubActionOutcome(outcome: ActionOutcomeV1): Promise<void>;
786
818
  topicDeliverable(
787
819
  identity: TurnIdentity,
@@ -1,3 +1,14 @@
1
+ import { taskCardSummary } from '../plugins/tasks/tasks-tools';
2
+ import {
3
+ ActionInteractionError,
4
+ type CheckClaimDispatch,
5
+ type CheckDispatch,
6
+ } from '@ixo/action-interactions';
7
+ import {
8
+ ActionInteractionStore,
9
+ interactionOutcome,
10
+ type InteractionCommand,
11
+ } from '../action-interactions/store';
1
12
  import {
2
13
  CHANNEL_DELIVERY_NOTICE_KWARG,
3
14
  channelDeliveryNote,
@@ -735,6 +746,11 @@ const META_LEGACY_CLEARED = 'meta:legacyCleared';
735
746
  */
736
747
  const META_LEGACY_UNUSABLE = 'meta:legacyUnusable';
737
748
  const META_USER_DID = 'meta:userDid';
749
+ /**
750
+ * Set when an evicted object keeps an alarm only for action-card draft
751
+ * retention; a boot clears it. The alarm then prunes on the bare object.
752
+ */
753
+ const META_RETENTION_WAKE = 'meta:interactionRetentionWake';
738
754
 
739
755
  /** KV key of a session's context-guard counters (`GET /debug/context?session=`). */
740
756
  const contextStatsKey = (sessionId: string): string => `ctxstats:${sessionId}`;
@@ -1690,6 +1706,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1690
1706
  throw new Error(`Object bound to ${stored}, not ${userDid}`);
1691
1707
  if (!stored) await this.ctx.storage.put(META_USER_DID, userDid);
1692
1708
  this.userDid = userDid;
1709
+ await this.ctx.storage.delete(META_RETENTION_WAKE);
1693
1710
 
1694
1711
  const env = this.env;
1695
1712
  // The gateway lazily starts on first use; make sure it is up before the
@@ -2769,6 +2786,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2769
2786
  // exists once the object booted).
2770
2787
  const keepAliveAt = this.runs?.keepAliveDeadline(wakeAt) ?? null;
2771
2788
  const recoveryAt = this.runs?.nextRecoveryAt() ?? null;
2789
+ // An evicted object (no working copy, no housekeeping deadline) with
2790
+ // no socket is only woken for action-card draft retention: prune on
2791
+ // the bare object instead of importing the owner copy, and re-arm.
2792
+ if (
2793
+ !this.db &&
2794
+ nextPingAt === null &&
2795
+ keepAliveAt === null &&
2796
+ recoveryAt === null &&
2797
+ (await this.ctx.storage.get<boolean>(META_RETENTION_WAKE)) === true
2798
+ ) {
2799
+ const next = await this.bareInteractionRetention();
2800
+ if (next !== null) await this.requestAlarm(next);
2801
+ else await this.ctx.storage.delete(META_RETENTION_WAKE);
2802
+ return;
2803
+ }
2772
2804
  if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000) {
2773
2805
  // A store error here must not reject the alarm: the tick below (or
2774
2806
  // the re-arm) still has to run.
@@ -2984,6 +3016,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2984
3016
  }
2985
3017
  }
2986
3018
 
3019
+ // Action-card drafts past retention: one bounded batch per tick, so a
3020
+ // user who stopped preparing cards still loses expired draft values.
3021
+ // Its follow-up is no reason to keep an idle copy either.
3022
+ if (this.userDid) {
3023
+ try {
3024
+ await new ActionInteractionStore(this.ctx.storage, {
3025
+ oracleDid: this.env.ORACLE_DID,
3026
+ userDid: this.userDid,
3027
+ }).prune();
3028
+ } catch (err) {
3029
+ console.warn(
3030
+ `[action-interactions] retention tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
3031
+ );
3032
+ }
3033
+ }
3034
+
2987
3035
  // Idle housekeeping: a user silent for five days loses the cached
2988
3036
  // pages — their file is safe upstream, and we stop paying to store a
2989
3037
  // copy. Never evict while tasks are scheduled: their runs ARE
@@ -3015,12 +3063,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3015
3063
  });
3016
3064
  if (outcome === 'evicted') {
3017
3065
  await this.ctx.storage.delete(META_HOUSEKEEPING_AT);
3066
+ // Action-card drafts outlive the working copy: their retention
3067
+ // still needs a wake (handled on the bare object, see alarm()).
3068
+ const retentionAt = await this.interactionRetentionAt();
3018
3069
  // An attached (idle) socket still needs its heartbeat rounds.
3019
- if (nextPingAt !== null) await this.requestAlarm(nextPingAt);
3070
+ if (nextPingAt !== null)
3071
+ await this.requestAlarm(
3072
+ Math.min(nextPingAt, retentionAt ?? Infinity),
3073
+ );
3020
3074
  // A request that waited on the wipe has booted the object afresh
3021
3075
  // and armed its own deadlines (a task Start's run): keep them.
3022
- else if (this.initPromise === null)
3023
- await this.ctx.storage.deleteAlarm();
3076
+ else if (this.initPromise === null) {
3077
+ if (retentionAt !== null) {
3078
+ await this.ctx.storage.put(META_RETENTION_WAKE, true);
3079
+ await this.requestAlarm(retentionAt);
3080
+ } else await this.ctx.storage.deleteAlarm();
3081
+ }
3024
3082
  return;
3025
3083
  }
3026
3084
  if (outcome === 'not-current') {
@@ -3196,6 +3254,162 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3196
3254
 
3197
3255
  // ── RPC surface (contracts.ts UserOracleObject) ───────────────────────
3198
3256
 
3257
+ private async interactionStore(
3258
+ identity: TurnIdentity,
3259
+ sessionId: string,
3260
+ options: { recordAccess?: boolean } = {},
3261
+ ) {
3262
+ await this.ready(identity, options);
3263
+ if (
3264
+ this.userDid !== identity.userDid ||
3265
+ !(await this.sessions?.getSession(sessionId))
3266
+ )
3267
+ throw new ActionInteractionError(404, 'not_found', 'Session not found');
3268
+ return new ActionInteractionStore(this.ctx.storage, {
3269
+ oracleDid: this.env.ORACLE_DID,
3270
+ userDid: identity.userDid,
3271
+ });
3272
+ }
3273
+
3274
+ async readActionInteractionTask(
3275
+ identity: TurnIdentity,
3276
+ sessionId: string,
3277
+ taskId: string,
3278
+ ) {
3279
+ return interactionOutcome(async () => {
3280
+ // The task scheduler needs a booted object, but an open task card polls every 15 s:
3281
+ // the poll is not activity, so it never keeps an idle object from being evicted.
3282
+ await this.interactionStore(identity, sessionId, {
3283
+ recordAccess: false,
3284
+ });
3285
+ const task = await this.taskScheduler?.surface.get(taskId);
3286
+ if (!task)
3287
+ throw new ActionInteractionError(404, 'not_found', 'Task not found');
3288
+ return JSON.stringify({ task: taskCardSummary(task) });
3289
+ });
3290
+ }
3291
+
3292
+ /**
3293
+ * The draft store without a boot: the object is addressed by the
3294
+ * caller's DID, and the stored binding is checked against it.
3295
+ */
3296
+ private async bareInteractionStore(identity: TurnIdentity) {
3297
+ const bound =
3298
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3299
+ if (bound && bound !== identity.userDid)
3300
+ throw new ActionInteractionError(404, 'not_found', 'Owner not found');
3301
+ return new ActionInteractionStore(this.ctx.storage, {
3302
+ oracleDid: this.env.ORACLE_DID,
3303
+ userDid: identity.userDid,
3304
+ });
3305
+ }
3306
+
3307
+ async checkDispatch(identity: TurnIdentity, input: CheckDispatch) {
3308
+ return interactionOutcome(async () => {
3309
+ // Runs before every bid, claim-block and approval effect: no boot, as in checkClaimDispatch.
3310
+ const store = await this.bareInteractionStore(identity);
3311
+ return JSON.stringify(await store.checkDispatch(input));
3312
+ });
3313
+ }
3314
+
3315
+ async checkClaimDispatch(
3316
+ identity: TurnIdentity,
3317
+ input: CheckClaimDispatch,
3318
+ ) {
3319
+ return interactionOutcome(async () => {
3320
+ // Every Portal claim calls this, Companion user or not, so it must
3321
+ // not boot the object (gateway, signer, SQLite, owner-copy import):
3322
+ // it only reads and writes a few keys. The object is addressed by
3323
+ // the caller's DID; the stored binding is checked without a boot.
3324
+ const store = await this.bareInteractionStore(identity);
3325
+ return JSON.stringify(await store.checkClaimDispatch(input));
3326
+ });
3327
+ }
3328
+
3329
+ /** When stored action-card drafts next need retention, if any are kept. */
3330
+ private async interactionRetentionAt(): Promise<number | null> {
3331
+ const userDid =
3332
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3333
+ if (!userDid) return null;
3334
+ return new ActionInteractionStore(this.ctx.storage, {
3335
+ oracleDid: this.env.ORACLE_DID,
3336
+ userDid,
3337
+ })
3338
+ .nextRetentionAt()
3339
+ .catch(() => null);
3340
+ }
3341
+
3342
+ /** One retention pass on an un-booted object; returns the next due time. */
3343
+ private async bareInteractionRetention(): Promise<number | null> {
3344
+ const userDid = await this.ctx.storage.get<string>(META_USER_DID);
3345
+ if (!userDid) return null;
3346
+ const store = new ActionInteractionStore(this.ctx.storage, {
3347
+ oracleDid: this.env.ORACLE_DID,
3348
+ userDid,
3349
+ });
3350
+ try {
3351
+ await store.prune();
3352
+ } catch (err) {
3353
+ console.warn(
3354
+ `[action-interactions] retention on an evicted object failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
3355
+ );
3356
+ return Date.now() + 60 * 60 * 1000;
3357
+ }
3358
+ const next = await store.nextRetentionAt().catch(() => null);
3359
+ // A backlog larger than one pass resumes a minute later.
3360
+ return next === null ? null : Math.max(next, Date.now() + 60_000);
3361
+ }
3362
+
3363
+ /** Drafts of a deleted chat, in bounded batches. */
3364
+ private async forgetInteractions(userDid: string, sessionId: string) {
3365
+ const store = new ActionInteractionStore(this.ctx.storage, {
3366
+ oracleDid: this.env.ORACLE_DID,
3367
+ userDid,
3368
+ });
3369
+ while ((await store.forgetSession(sessionId)) > 0);
3370
+ }
3371
+
3372
+ async actionInteraction(
3373
+ identity: TurnIdentity,
3374
+ command: InteractionCommand,
3375
+ ) {
3376
+ return interactionOutcome(async () => {
3377
+ const store = await this.interactionStore(
3378
+ identity,
3379
+ command.input.sessionId,
3380
+ );
3381
+ if (
3382
+ command.kind === 'prepare' &&
3383
+ command.input.owner.kind === 'task' &&
3384
+ !(await this.taskScheduler?.surface.get(command.input.owner.taskId))
3385
+ )
3386
+ throw new ActionInteractionError(404, 'not_found', 'Task not found');
3387
+ return JSON.stringify(await store.command(command));
3388
+ });
3389
+ }
3390
+
3391
+ async readActionInteraction(
3392
+ identity: TurnIdentity,
3393
+ sessionId: string,
3394
+ id?: string,
3395
+ references = false,
3396
+ ) {
3397
+ return interactionOutcome(async () => {
3398
+ // Card polls never boot the object or count as activity: they read a
3399
+ // few keys, so an open card neither imports an evicted owner copy nor
3400
+ // keeps an idle object alive. A deleted chat's drafts are deleted
3401
+ // with it, so no session lookup is needed to hide them.
3402
+ const store = await this.bareInteractionStore(identity);
3403
+ return JSON.stringify(
3404
+ await (id
3405
+ ? store.read(id, sessionId)
3406
+ : references
3407
+ ? store.listReferences(sessionId)
3408
+ : store.list(sessionId)),
3409
+ );
3410
+ });
3411
+ }
3412
+
3199
3413
  async createSession(
3200
3414
  identity: TurnIdentity,
3201
3415
  o: { roomId?: string; sessionId?: string; markerTxnId?: string } = {},
@@ -4112,6 +4326,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4112
4326
  });
4113
4327
  await this.ctx.storage.delete(contextStatsKey(sessionId));
4114
4328
  await this.feedbackMarkers!.forgetSession(sessionId);
4329
+ // The chat's action-card drafts go with it; a dispatched claim keeps
4330
+ // only the tombstones that stop a duplicate.
4331
+ await this.forgetInteractions(identity.userDid, sessionId).catch(
4332
+ (err: unknown) => {
4333
+ console.warn(
4334
+ `[action-interactions] could not delete the drafts of ${sessionId}; retention removes them later: ${err instanceof Error ? err.message : String(err)}`,
4335
+ );
4336
+ },
4337
+ );
4115
4338
  this.markDirty();
4116
4339
  }
4117
4340
  // A channel binding that pointed at the session opens a new one on
@@ -33,8 +33,9 @@ import {
33
33
  const manifest: PluginManifest = {
34
34
  title: 'Portal',
35
35
  summary:
36
- "Browser-side actions on the user's Portal UI — opens URLs, manipulates the DOM, runs FE-defined browser tools.",
36
+ "The user's Portal UI: action cards (bids, claims, questions, approvals), forms, Topics, domains, pages, navigation.",
37
37
  whenToUse: [
38
+ 'User asks to prepare or submit a bid or claim: call `list_action_cards`, then `prepare_action_card`.',
38
39
  'User asks for an action the Portal FE exposes as a browser tool (declared in `state.browserTools`).',
39
40
  "A task needs a browser-side capability the server can't do alone — open a URL in the user's tab, click a Portal button, fill a form.",
40
41
  ],
@@ -95,6 +95,25 @@ function summarizeRecord(record: OracleTaskRecord): Record<string, unknown> {
95
95
  };
96
96
  }
97
97
 
98
+ /** Current owner summary shared by get_task and private card readback. */
99
+ export function taskCardSummary(
100
+ record: OracleTaskRecord,
101
+ ): Record<string, unknown> {
102
+ return {
103
+ ok: true,
104
+ ...summarizeRecord(record),
105
+ ...(record.executionProfile
106
+ ? { note: RESTRICTED_TASK_NOTE }
107
+ : { intent: record.intent }),
108
+ lastRunAt: record.lastRunAt ?? null,
109
+ lastResult:
110
+ record.executionProfile && record.lastResult
111
+ ? { ok: record.lastResult.ok, at: record.lastResult.at }
112
+ : (record.lastResult ?? null),
113
+ consecutiveFailures: record.consecutiveFailures,
114
+ };
115
+ }
116
+
98
117
  function failure(err: unknown): { ok: false; error: string } {
99
118
  return {
100
119
  ok: false,
@@ -242,26 +261,7 @@ function getTask(): PluginTool {
242
261
  try {
243
262
  const record = await tasks.get(taskId);
244
263
  if (!record) return { ok: false, error: 'Task not found.' };
245
- if (record.executionProfile) {
246
- return {
247
- ok: true,
248
- ...summarizeRecord(record),
249
- lastRunAt: record.lastRunAt ?? null,
250
- lastResult: record.lastResult
251
- ? { ok: record.lastResult.ok, at: record.lastResult.at }
252
- : null,
253
- consecutiveFailures: record.consecutiveFailures,
254
- note: RESTRICTED_TASK_NOTE,
255
- };
256
- }
257
- return {
258
- ok: true,
259
- ...summarizeRecord(record),
260
- intent: record.intent,
261
- lastRunAt: record.lastRunAt ?? null,
262
- lastResult: record.lastResult ?? null,
263
- consecutiveFailures: record.consecutiveFailures,
264
- };
264
+ return taskCardSummary(record);
265
265
  } catch (err) {
266
266
  return failure(err);
267
267
  }