@ixo/oracle-runtime-workers 0.39.0 → 0.41.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.
- package/package.json +3 -2
- package/src/action-interactions/routes.ts +78 -0
- package/src/action-interactions/store.ts +1038 -0
- package/src/core/middlewares/tool-repetition-guard.ts +61 -1
- package/src/do/contracts.ts +32 -0
- package/src/do/user-oracle-do.ts +226 -3
- package/src/index.ts +2 -0
- package/src/plugins/asks/asks.plugin.ts +226 -0
- package/src/plugins/asks/attach.ts +342 -0
- package/src/plugins/asks/connection-do.ts +69 -0
- package/src/plugins/asks/connection.ts +368 -0
- package/src/plugins/asks/guide.ts +18 -0
- package/src/plugins/asks/index.ts +5 -0
- package/src/plugins/asks/oauth.ts +381 -0
- package/src/plugins/asks/routes.ts +258 -0
- package/src/plugins/asks/skill-pick.ts +114 -0
- package/src/plugins/asks/skills.bundled.json +27 -0
- package/src/plugins/asks/skills.ts +207 -0
- package/src/plugins/asks/testing.ts +147 -0
- package/src/plugins/asks/tools.ts +714 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/portal/portal.plugin.ts +2 -1
- package/src/plugins/tasks/tasks-tools.ts +20 -20
- package/src/shell/app.ts +241 -6
|
@@ -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 = (
|
|
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 },
|
package/src/do/contracts.ts
CHANGED
|
@@ -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,
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,8 @@ export {
|
|
|
54
54
|
} from './owner-store/ixo-vfs-store';
|
|
55
55
|
export { MatrixMediaOwnerStore } from './owner-store/matrix-media-store';
|
|
56
56
|
export { MatrixGatewayDO } from './matrix/gateway-do';
|
|
57
|
+
/** asks.world sign-ins, one per person; a host re-exports it under this name (`docs/asks.md`). */
|
|
58
|
+
export { AsksConnectionDO } from './plugins/asks/connection-do';
|
|
57
59
|
export {
|
|
58
60
|
createUserOracleDO,
|
|
59
61
|
type OracleWorkerHooks,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineDecision,
|
|
3
|
+
OraclePlugin,
|
|
4
|
+
type AgentMiddleware,
|
|
5
|
+
type AuthExcludedRoute,
|
|
6
|
+
type MergedConfig,
|
|
7
|
+
type PluginEnv,
|
|
8
|
+
type PluginManifest,
|
|
9
|
+
type PluginRoute,
|
|
10
|
+
type PluginTool,
|
|
11
|
+
type RuntimeContext,
|
|
12
|
+
} from '../../plugin-api';
|
|
13
|
+
import { createMiddleware } from 'langchain';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { channelOriginsOf } from './attach';
|
|
16
|
+
import type { AsksWorkerEnv } from './connection';
|
|
17
|
+
import { ASKS_OPERATING_GUIDE } from './guide';
|
|
18
|
+
import { asksMcpUrlOf, deriveKeys, objectNameOf, type AsksKeys } from './oauth';
|
|
19
|
+
import { asksRoutes, connectionOf } from './routes';
|
|
20
|
+
import {
|
|
21
|
+
asksSkillDecisionOptions,
|
|
22
|
+
lastHumanText,
|
|
23
|
+
pickSkill,
|
|
24
|
+
} from './skill-pick';
|
|
25
|
+
import {
|
|
26
|
+
AsksSkills,
|
|
27
|
+
folderOf,
|
|
28
|
+
workersSkillCache,
|
|
29
|
+
type Skill,
|
|
30
|
+
type SkillFolder,
|
|
31
|
+
} from './skills';
|
|
32
|
+
import {
|
|
33
|
+
asksTools,
|
|
34
|
+
connectAsksMcp,
|
|
35
|
+
keepTurnFiles,
|
|
36
|
+
preloadText,
|
|
37
|
+
type McpConnect,
|
|
38
|
+
} from './tools';
|
|
39
|
+
|
|
40
|
+
const configSchema = z.object({
|
|
41
|
+
/** Seals the tokens and names each person's connection object (`openssl rand -base64 32`). */
|
|
42
|
+
ASKS_TOKEN_KEY: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(32, 'ASKS_TOKEN_KEY must be at least 32 characters.'),
|
|
45
|
+
/** `https://wa.me/<number>?text=…`: the callback page's "Back to WhatsApp". */
|
|
46
|
+
ASKS_WHATSAPP_RETURN_URL: z.string().url().optional(),
|
|
47
|
+
/** Pilot: comma-separated user DIDs who get asks. Unset: everyone. */
|
|
48
|
+
ASKS_ALLOWED_DIDS: z.string().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const manifest: PluginManifest = {
|
|
52
|
+
title: 'asks.world',
|
|
53
|
+
summary:
|
|
54
|
+
'asks.world: find paid work and get paid, post asks real people answer, get an app or agent reviewed by verified pros, and see or cash out asks money.',
|
|
55
|
+
// The on-demand catalogue's line (runtime 0.39+, at most 60 characters).
|
|
56
|
+
brief: 'paid work, asks people answer, pro reviews, asks money',
|
|
57
|
+
whenToUse: [
|
|
58
|
+
'The person mentions asks.world or an ask, wants paid work or to apply to one, has an invitation to an ask, or wants to hand in an answer or a report.',
|
|
59
|
+
'The person wants to hire someone, have something checked or found out somewhere they cannot see, or get an app or agent reviewed by pros.',
|
|
60
|
+
'The person asks about their asks money, earnings or cash-out, their pro page, or whether anyone answered their ask.',
|
|
61
|
+
],
|
|
62
|
+
whenNotToUse: [
|
|
63
|
+
'IXO wallet balances, chain transactions or other on-chain actions (asks money is separate).',
|
|
64
|
+
'General web research or tasks that need no person on asks.world.',
|
|
65
|
+
],
|
|
66
|
+
tags: ['asks', 'work', 'jobs', 'payments', 'reviews'],
|
|
67
|
+
category: 'integration',
|
|
68
|
+
visibility: 'on-demand',
|
|
69
|
+
stability: 'beta',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface AsksPluginOptions {
|
|
73
|
+
/** The Worker env: the binding, vars and secret. Default: `env` from `cloudflare:workers`. */
|
|
74
|
+
env?: () => AsksWorkerEnv;
|
|
75
|
+
connect?: McpConnect;
|
|
76
|
+
fetch?: typeof fetch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Loaded on first use, so the plain-Node test suites can import the plugin. */
|
|
80
|
+
const workerEnv = async (): Promise<AsksWorkerEnv> => {
|
|
81
|
+
// The host's Env type is its own; the plugin reads only its optional keys.
|
|
82
|
+
const env: object = (await import('cloudflare:workers')).env;
|
|
83
|
+
return env;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* asks.world through its hosted connector (`/v1/mcp` on the asks host for
|
|
88
|
+
* `NETWORK`), signed in once per person with OAuth (asks' Google login).
|
|
89
|
+
* See `docs/asks.md` and `guide.ts` for how the model is told to use it.
|
|
90
|
+
*/
|
|
91
|
+
export class AsksPlugin extends OraclePlugin {
|
|
92
|
+
static readonly NAME = 'asks';
|
|
93
|
+
|
|
94
|
+
readonly name = AsksPlugin.NAME;
|
|
95
|
+
|
|
96
|
+
readonly version = '1.0.0';
|
|
97
|
+
|
|
98
|
+
readonly manifest = manifest;
|
|
99
|
+
|
|
100
|
+
override readonly operatingGuide = ASKS_OPERATING_GUIDE;
|
|
101
|
+
|
|
102
|
+
override readonly configSchema = configSchema;
|
|
103
|
+
|
|
104
|
+
override readonly autoDetectHint =
|
|
105
|
+
'ASKS_TOKEN_KEY, ORACLE_PUBLIC_URL and NETWORK (mainnet, testnet or devnet)';
|
|
106
|
+
|
|
107
|
+
private keys: Promise<AsksKeys> | null = null;
|
|
108
|
+
|
|
109
|
+
private readonly skillSets = new Map<SkillFolder, AsksSkills>();
|
|
110
|
+
|
|
111
|
+
private readonly decision = defineDecision(asksSkillDecisionOptions);
|
|
112
|
+
|
|
113
|
+
constructor(private readonly options: AsksPluginOptions = {}) {
|
|
114
|
+
super();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override autoDetect(env: PluginEnv): boolean {
|
|
118
|
+
// The secret too: a deploy before it is set leaves asks off instead of failing the boot.
|
|
119
|
+
const set = ['ASKS_TOKEN_KEY', 'ORACLE_PUBLIC_URL'].every(
|
|
120
|
+
(key) => typeof env[key] === 'string' && env[key].length > 0,
|
|
121
|
+
);
|
|
122
|
+
if (!set) return false;
|
|
123
|
+
if (asksMcpUrlOf(env['NETWORK'])) return true;
|
|
124
|
+
// Never guess a network: the wrong one is the wrong money. No logger here.
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.warn(
|
|
127
|
+
`[asks] off: NETWORK is ${typeof env['NETWORK'] === 'string' ? `"${env['NETWORK']}"` : 'not set'}; asks needs mainnet, testnet or devnet`,
|
|
128
|
+
);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private allowed(config: MergedConfig, did: string): boolean {
|
|
133
|
+
const list = configSchema.parse(config).ASKS_ALLOWED_DIDS;
|
|
134
|
+
return !list?.trim() || list.split(/[\s,]+/).includes(did);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private skillsFor(rtCtx: RuntimeContext): Promise<Skill[]> {
|
|
138
|
+
const folder = folderOf(rtCtx.config.NETWORK);
|
|
139
|
+
let set = this.skillSets.get(folder);
|
|
140
|
+
if (!set) {
|
|
141
|
+
set = new AsksSkills({
|
|
142
|
+
folder,
|
|
143
|
+
fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
|
|
144
|
+
now: () => Date.now(),
|
|
145
|
+
cache: workersSkillCache(),
|
|
146
|
+
warn: (line) => rtCtx.logger.warn(line),
|
|
147
|
+
});
|
|
148
|
+
this.skillSets.set(folder, set);
|
|
149
|
+
}
|
|
150
|
+
return set.skills(rtCtx.background);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
|
|
154
|
+
if (!this.allowed(rtCtx.config, rtCtx.user.did)) return [];
|
|
155
|
+
const config = configSchema.parse(rtCtx.config);
|
|
156
|
+
const mcpUrl = asksMcpUrlOf(rtCtx.config.NETWORK);
|
|
157
|
+
if (!mcpUrl) return [];
|
|
158
|
+
const env = this.options.env ? this.options.env() : await workerEnv();
|
|
159
|
+
this.keys ??= deriveKeys(config.ASKS_TOKEN_KEY);
|
|
160
|
+
const objectName = await objectNameOf(await this.keys, rtCtx.user.did);
|
|
161
|
+
const conn = connectionOf(env, objectName);
|
|
162
|
+
if (!conn) {
|
|
163
|
+
rtCtx.logger.warn(
|
|
164
|
+
'[asks] the ASKS_CONNECTIONS binding is missing: no asks tools',
|
|
165
|
+
);
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
await keepTurnFiles(rtCtx).catch((error: unknown) =>
|
|
169
|
+
rtCtx.logger.warn(
|
|
170
|
+
`[asks] could not keep this turn's files: ${String(error)}`,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
return asksTools({
|
|
174
|
+
ctx: rtCtx,
|
|
175
|
+
conn,
|
|
176
|
+
mcpUrl,
|
|
177
|
+
channelOrigins: channelOriginsOf(env.CHANNEL_SERVICE_DID),
|
|
178
|
+
skills: await this.skillsFor(rtCtx),
|
|
179
|
+
connect: this.options.connect ?? connectAsksMcp,
|
|
180
|
+
fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
|
|
181
|
+
now: () => Date.now(),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** With asks loaded for the turn: the skill the message needs, in the prompt before the first model call. */
|
|
186
|
+
override async getRequestMiddlewares(
|
|
187
|
+
rtCtx: RuntimeContext,
|
|
188
|
+
): Promise<AgentMiddleware[]> {
|
|
189
|
+
if (
|
|
190
|
+
!rtCtx.loadedPlugins.has(this.name) ||
|
|
191
|
+
!this.allowed(rtCtx.config, rtCtx.user.did)
|
|
192
|
+
)
|
|
193
|
+
return [];
|
|
194
|
+
const skills = await this.skillsFor(rtCtx);
|
|
195
|
+
let picked: Promise<Skill | null> | null = null;
|
|
196
|
+
return [
|
|
197
|
+
createMiddleware({
|
|
198
|
+
name: 'AsksSkillPreload',
|
|
199
|
+
wrapModelCall: async (request, handler) => {
|
|
200
|
+
picked ??= pickSkill(
|
|
201
|
+
rtCtx.decisions,
|
|
202
|
+
this.decision,
|
|
203
|
+
lastHumanText(request.messages),
|
|
204
|
+
skills,
|
|
205
|
+
rtCtx.abortSignal,
|
|
206
|
+
);
|
|
207
|
+
const skill = await picked;
|
|
208
|
+
return skill
|
|
209
|
+
? handler({
|
|
210
|
+
...request,
|
|
211
|
+
systemMessage: request.systemMessage.concat(preloadText(skill)),
|
|
212
|
+
})
|
|
213
|
+
: handler(request);
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
override getRoutes(): PluginRoute[] {
|
|
220
|
+
return asksRoutes();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
override getAuthExcludedRoutes(): AuthExcludedRoute[] {
|
|
224
|
+
return asksRoutes().map(({ path }) => ({ path, method: 'GET' as const }));
|
|
225
|
+
}
|
|
226
|
+
}
|