@hachej/boring-agent 0.1.111 → 0.2.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.
- package/LICENSE +1 -1
- package/README.md +262 -51
- package/dist/agents.d.ts +75 -0
- package/dist/agents.js +91 -0
- package/dist/approval.d.ts +71 -0
- package/dist/approval.js +126 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/ask-user.d.ts +40 -0
- package/dist/ask-user.js +134 -0
- package/dist/attachment.d.ts +5 -0
- package/dist/attachment.js +4 -0
- package/dist/browser-task.d.ts +32 -0
- package/dist/browser-task.js +119 -0
- package/dist/chat-transport.d.ts +80 -0
- package/dist/chat-transport.js +306 -0
- package/dist/contracts.d.ts +40 -0
- package/dist/contracts.js +1 -0
- package/dist/conversations.d.ts +136 -0
- package/dist/conversations.js +366 -0
- package/dist/definitions.d.ts +37 -0
- package/dist/definitions.js +99 -0
- package/dist/delivery-input.d.ts +35 -0
- package/dist/delivery-input.js +56 -0
- package/dist/delivery.d.ts +33 -0
- package/dist/delivery.js +154 -0
- package/dist/durable-wait.d.ts +23 -0
- package/dist/durable-wait.js +79 -0
- package/dist/file-convert.d.ts +46 -0
- package/dist/file-convert.js +159 -0
- package/dist/file-guard.d.ts +50 -0
- package/dist/file-guard.js +177 -0
- package/dist/file-types.d.ts +17 -0
- package/dist/file-types.js +95 -0
- package/dist/gateway-provider.d.ts +24 -0
- package/dist/gateway-provider.js +42 -0
- package/dist/git.d.ts +34 -0
- package/dist/git.js +79 -0
- package/dist/harness-pool.d.ts +38 -0
- package/dist/harness-pool.js +134 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/json-schema-tool.d.ts +25 -0
- package/dist/json-schema-tool.js +19 -0
- package/dist/memory/optchat-core.d.ts +118 -0
- package/dist/memory/optchat-core.js +317 -0
- package/dist/memory/optchat.d.ts +156 -0
- package/dist/memory/optchat.js +432 -0
- package/dist/mentions.d.ts +56 -0
- package/dist/mentions.js +140 -0
- package/dist/metering.d.ts +183 -0
- package/dist/metering.js +443 -0
- package/dist/model-gateway.d.ts +86 -0
- package/dist/model-gateway.js +241 -0
- package/dist/native-version.d.ts +9 -0
- package/dist/native-version.js +7 -0
- package/dist/native.d.ts +4 -0
- package/dist/native.js +1 -0
- package/dist/presentation.d.ts +17 -0
- package/dist/presentation.js +39 -0
- package/dist/projection-format.d.ts +68 -0
- package/dist/projection-format.js +118 -0
- package/dist/projection-window.d.ts +9 -0
- package/dist/projection-window.js +93 -0
- package/dist/projection.d.ts +22 -0
- package/dist/projection.js +153 -0
- package/dist/question-response.d.ts +20 -0
- package/dist/question-response.js +75 -0
- package/dist/questions.d.ts +74 -0
- package/dist/questions.js +199 -0
- package/dist/self-evolving.d.ts +55 -0
- package/dist/self-evolving.js +239 -0
- package/dist/skills.d.ts +15 -0
- package/dist/skills.js +51 -0
- package/dist/tool-evidence.d.ts +14 -0
- package/dist/tool-evidence.js +54 -0
- package/dist/validation.d.ts +38 -0
- package/dist/validation.js +68 -0
- package/dist/workspaces.d.ts +107 -0
- package/dist/workspaces.js +212 -0
- package/package.json +145 -150
- package/dist/DebugDrawer-JNA43SVR.js +0 -8
- package/dist/admin-DORVQfLn.d.ts +0 -340
- package/dist/chunk-2BGLZZ7T.js +0 -10
- package/dist/chunk-42D6KAQV.js +0 -2882
- package/dist/chunk-4XOW57KJ.js +0 -343
- package/dist/chunk-72KT6VWV.js +0 -1338
- package/dist/chunk-7XAOVFSN.js +0 -28
- package/dist/chunk-KJIEMSQP.js +0 -198
- package/dist/chunk-KN72XWP7.js +0 -394
- package/dist/chunk-N6THTH7P.js +0 -110
- package/dist/chunk-V3H5A3BR.js +0 -22501
- package/dist/chunk-YGRQ4Q5P.js +0 -20
- package/dist/chunk-YTUZFKB3.js +0 -78
- package/dist/core/index.d.ts +0 -28
- package/dist/core/index.js +0 -617
- package/dist/createHarness-DOLOHA62.js +0 -18
- package/dist/eval/index.d.ts +0 -247
- package/dist/eval/index.js +0 -541
- package/dist/file-search-BUgMSKZE.d.ts +0 -289
- package/dist/front/artifacts.d.ts +0 -34
- package/dist/front/artifacts.js +0 -21
- package/dist/front/index.d.ts +0 -1082
- package/dist/front/index.js +0 -12912
- package/dist/front/styles.css +0 -5380
- package/dist/harness-qHlMJhZo.d.ts +0 -276
- package/dist/middleware-CaAEXpi5.d.ts +0 -115
- package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
- package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
- package/dist/sandbox-BMhut3kh.d.ts +0 -317
- package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
- package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
- package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
- package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
- package/dist/server/index.d.ts +0 -2278
- package/dist/server/index.js +0 -274
- package/dist/server/pi-session-readability.d.ts +0 -12
- package/dist/server/pi-session-readability.js +0 -23
- package/dist/server/worker/index.d.ts +0 -68
- package/dist/server/worker/index.js +0 -377
- package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
- package/dist/shared/index.d.ts +0 -2111
- package/dist/shared/index.js +0 -262
- package/dist/types-DcWoQMn8.d.ts +0 -1377
- package/docs/ACCESSIBILITY.md +0 -52
- package/docs/AGENT_GATEWAY_V0.md +0 -212
- package/docs/AGENT_PACKAGES.md +0 -90
- package/docs/API.md +0 -123
- package/docs/AUTHORING.md +0 -68
- package/docs/CSP.md +0 -41
- package/docs/ERROR_CODES.md +0 -229
- package/docs/KNOWN_LIMITATIONS.md +0 -100
- package/docs/MIGRATION.md +0 -50
- package/docs/PERFORMANCE.md +0 -68
- package/docs/PLUGINS.md +0 -138
- package/docs/README.md +0 -98
- package/docs/RISKS-MULTI-TAB.md +0 -46
- package/docs/SESSION_IDENTITY.md +0 -91
- package/docs/STYLING.md +0 -73
- package/docs/UI-SHADCN.md +0 -56
- package/docs/VERCEL_COSTS.md +0 -103
- package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
- package/docs/plans/archive/agent-package-spec.md +0 -1777
- package/docs/plans/archive/harness-followup-capabilities.md +0 -440
- package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
- package/docs/plans/archive/pi-followup-history-projection.md +0 -229
- package/docs/plans/archive/pi-tools-migration.md +0 -1061
- package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
- package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
- package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
- package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
- package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
- package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
- package/docs/runtime-provisioning.md +0 -157
- package/docs/runtime.md +0 -296
- package/docs/tools.md +0 -102
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @hachej/boring-agent/browser-task: a native Pi tool that hands a task to the person's open page and waits, durably, for the page's
|
|
2
|
+
// structured answer: the same wait as `ask_user` (./durable-wait.ts) with a JSON object for an answer instead of a short text.
|
|
3
|
+
// The page finds the pending call in the conversation it already watches (the tool call with no result yet), does the task with
|
|
4
|
+
// the person (for example a live preview of a change, `browser_preview` in `@hachej/boring-feedback/agent`) and answers through the same
|
|
5
|
+
// authenticated path as a question: the chat transport's `?op=answer`, with the call's `[assistantEntryId, callId]` binding.
|
|
6
|
+
// Nothing runs in the page because of this tool alone; the host decides which page code picks the task up.
|
|
7
|
+
import { defineDocFamily, defineTool } from '@earendil-works/pi-durable';
|
|
8
|
+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
9
|
+
import { AmbiguousCall, UnknownCall, callBinding, callBindingId, ownBindingKey, waitForDocument } from './durable-wait.js';
|
|
10
|
+
/** Upper bound on an answer's UTF-8 JSON, by default. */
|
|
11
|
+
export const BROWSER_TASK_MAX_ANSWER = 16_384;
|
|
12
|
+
/** One member per assistant entry and tool call, in the calling conversation. */
|
|
13
|
+
const documents = defineDocFamily({
|
|
14
|
+
kind: 'boring.browser-task', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
|
|
15
|
+
initial: () => ({ task: null }),
|
|
16
|
+
});
|
|
17
|
+
/** The answer ID of a browser task call: the same `[assistantEntryId, callId]` binding as `askUserQuestionId`. */
|
|
18
|
+
export const browserTaskId = callBindingId;
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
/**
|
|
22
|
+
* A native Pi tool that waits for the person's page to answer, without holding a model request. Pending calls survive a restart
|
|
23
|
+
* (the replay-safe call re-attaches to its record) and are cancelled with the call when the conversation is stopped. The result
|
|
24
|
+
* text is the page's answer, as JSON. Answer it with `answerBrowserTask`.
|
|
25
|
+
*/
|
|
26
|
+
export function createBrowserTaskTool(options) {
|
|
27
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(options.name))
|
|
28
|
+
throw new TypeError('A browser task needs a snake_case tool name');
|
|
29
|
+
return defineTool({
|
|
30
|
+
name: options.name,
|
|
31
|
+
description: options.description,
|
|
32
|
+
parameters: options.parameters,
|
|
33
|
+
replay: 'safe',
|
|
34
|
+
execute: async (_args, api, context) => {
|
|
35
|
+
const key = await api.commit(async (tx) => {
|
|
36
|
+
const key = await ownBindingKey(tx, api);
|
|
37
|
+
const document = await tx.doc(documents, api.conversationId, key, null);
|
|
38
|
+
if (!document.task)
|
|
39
|
+
document.task = { tool: options.name, taskId: api.taskId, state: { kind: 'pending' } };
|
|
40
|
+
if (document.task.taskId !== api.taskId)
|
|
41
|
+
throw new Error('Browser task identity belongs to another task');
|
|
42
|
+
return key;
|
|
43
|
+
}, context);
|
|
44
|
+
const final = await waitForDocument(api, documents, key, context, value => value?.task?.state.kind !== 'pending', async (tx) => {
|
|
45
|
+
const { task } = await tx.doc(documents, api.conversationId, key, null);
|
|
46
|
+
if (task?.state.kind === 'pending')
|
|
47
|
+
task.state = { kind: 'cancelled' };
|
|
48
|
+
}, 'Browser task');
|
|
49
|
+
const state = final?.task?.state;
|
|
50
|
+
if (state?.kind !== 'answered')
|
|
51
|
+
throw new Error('Browser task cancelled');
|
|
52
|
+
return { content: [{ type: 'text', text: JSON.stringify(state.answer) }] };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Settles a pending browser task with the page's answer (a JSON object as text) after the host authenticated the person. `id` is
|
|
58
|
+
* `browserTaskId(assistantEntryId, callId)` (an unscoped call ID only when exactly one native task used it). The answer goes through
|
|
59
|
+
* the tool's own `checkAnswer` (pass the same options as the tool). Exactly one answer is accepted; repeating it is idempotent.
|
|
60
|
+
* Returns the `ask_user` outcome kinds, so one chat transport `answer` serves both.
|
|
61
|
+
*/
|
|
62
|
+
export async function answerBrowserTask(conversation, tool, id, answer, context = BACKGROUND_CONTEXT) {
|
|
63
|
+
if (typeof id !== 'string' || !id || id.length > 1024)
|
|
64
|
+
return { kind: 'unknown-question' };
|
|
65
|
+
if (typeof answer !== 'string')
|
|
66
|
+
return { kind: 'denied', reason: 'The answer must be JSON text' };
|
|
67
|
+
const max = tool.maxAnswerBytes ?? BROWSER_TASK_MAX_ANSWER;
|
|
68
|
+
if (encoder.encode(answer).length > max)
|
|
69
|
+
return { kind: 'denied', reason: `The answer is larger than ${max} bytes` };
|
|
70
|
+
let value;
|
|
71
|
+
try {
|
|
72
|
+
value = JSON.parse(answer);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { kind: 'denied', reason: 'The answer is not JSON' };
|
|
76
|
+
}
|
|
77
|
+
if (!isObject(value))
|
|
78
|
+
return { kind: 'denied', reason: 'The answer must be a JSON object' };
|
|
79
|
+
const parsed = value;
|
|
80
|
+
try {
|
|
81
|
+
return await conversation.commit(async (tx) => {
|
|
82
|
+
const binding = await callBinding(tx, conversation, id);
|
|
83
|
+
const task = await tx.task(binding.taskId);
|
|
84
|
+
if (!task)
|
|
85
|
+
return { kind: 'conflict', reason: 'The browser task is unavailable' };
|
|
86
|
+
const document = await tx.doc(documents, conversation.id, binding.key, null);
|
|
87
|
+
// Throwing rolls back the empty draft an unknown or foreign call would leave behind.
|
|
88
|
+
if (!document.task || document.task.tool !== tool.name)
|
|
89
|
+
throw new UnknownCall();
|
|
90
|
+
const record = document.task;
|
|
91
|
+
if (record.taskId !== task.id)
|
|
92
|
+
return { kind: 'conflict', reason: 'The browser task belongs to another task' };
|
|
93
|
+
if (task.abortRequested) {
|
|
94
|
+
record.state = { kind: 'cancelled' };
|
|
95
|
+
return { kind: 'conflict', reason: 'The browser task was cancelled' };
|
|
96
|
+
}
|
|
97
|
+
if (record.state.kind === 'answered')
|
|
98
|
+
return JSON.stringify(record.state.answer) === JSON.stringify(parsed) ? { kind: 'answered' } : { kind: 'conflict', reason: 'The browser task was already answered' };
|
|
99
|
+
if (record.state.kind === 'cancelled')
|
|
100
|
+
return { kind: 'conflict', reason: 'The browser task was cancelled' };
|
|
101
|
+
if (task.state.status === 'terminal') {
|
|
102
|
+
record.state = { kind: 'cancelled' };
|
|
103
|
+
return { kind: 'conflict', reason: 'The browser task was cancelled' };
|
|
104
|
+
}
|
|
105
|
+
const problem = tool.checkAnswer(parsed);
|
|
106
|
+
if (problem)
|
|
107
|
+
return { kind: 'denied', reason: problem };
|
|
108
|
+
record.state = { kind: 'answered', answer: parsed };
|
|
109
|
+
return { kind: 'answered' };
|
|
110
|
+
}, context);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof AmbiguousCall)
|
|
114
|
+
return { kind: 'conflict', reason: 'Use the assistant-entry-bound call ID' };
|
|
115
|
+
if (error instanceof UnknownCall)
|
|
116
|
+
return { kind: 'unknown-question' };
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord';
|
|
2
|
+
import type { Conversation, ConversationView, InputSubmissionDraft, SubmissionId } from '@earendil-works/pi-durable';
|
|
3
|
+
/** Wire protocol shared with the browser client in `@hachej/boring-ui-kit/remote-chat`. */
|
|
4
|
+
export declare const CHAT_TRANSPORT: Readonly<{
|
|
5
|
+
schema: "boring.chat-transport";
|
|
6
|
+
version: 1;
|
|
7
|
+
maxRequestBytes: 8388608;
|
|
8
|
+
maxHistoryPage: 200;
|
|
9
|
+
maxPendingFrames: 100;
|
|
10
|
+
heartbeatMs: 15000;
|
|
11
|
+
}>;
|
|
12
|
+
/** The agent change a browser may ask for. Names only: the host maps it to a native `AgentChange`. */
|
|
13
|
+
export interface ChatConfigureChange {
|
|
14
|
+
readonly model?: {
|
|
15
|
+
readonly provider: string;
|
|
16
|
+
readonly modelId: string;
|
|
17
|
+
};
|
|
18
|
+
readonly thinkingLevel?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ChatTransportAccess {
|
|
21
|
+
readonly conversation: Conversation;
|
|
22
|
+
readonly context: Context;
|
|
23
|
+
/** The host aborts on revocation or identity change; open watches then end. */
|
|
24
|
+
readonly revoked?: AbortSignal;
|
|
25
|
+
/**
|
|
26
|
+
* Remove fields the caller may not see. Without it the raw native view is sent to a trusted viewer. With it, raw
|
|
27
|
+
* conversation data never leaves the host: every read (the watch, `?op=entries` history pages, `?op=submission`) goes
|
|
28
|
+
* through it. History pages are projected as a view holding just the page's entries and no documents.
|
|
29
|
+
*/
|
|
30
|
+
readonly project?: (view: ConversationView) => ConversationView;
|
|
31
|
+
/** Resolve a pending `ask_user` call. The host authenticates the person; without it `?op=answer` is not supported. */
|
|
32
|
+
readonly answer?: (questionId: string, answer: string) => Promise<unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Change the conversation's native agent (`conversation.configure`). The host validates the change against its own
|
|
35
|
+
* allow-list and returns a refusal (for example `{ kind: 'refused', reason }`) instead of applying it; this handler
|
|
36
|
+
* never decides which models or levels are allowed. Without it `?op=configure` is not supported.
|
|
37
|
+
*/
|
|
38
|
+
readonly configure?: (change: ChatConfigureChange) => Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* Improve a submitted input on the host before it reaches the native conversation, for example resolve `@path`
|
|
41
|
+
* mentions into file content with `createMentionResolver` from `@hachej/boring-agent/mentions`. It must return the
|
|
42
|
+
* input to submit; if it throws, the original input is submitted unchanged so a message is never lost.
|
|
43
|
+
*/
|
|
44
|
+
readonly prepareInput?: (content: InputSubmissionDraft['content']) => Promise<InputSubmissionDraft['content']>;
|
|
45
|
+
/**
|
|
46
|
+
* Withdraw one queued input: the host binds the native `Harness.abortSubmission(id, context, conversation.id)`, which
|
|
47
|
+
* scopes the id to this conversation. Without it `?op=withdraw` is not supported. This handler never edits the inbox.
|
|
48
|
+
*/
|
|
49
|
+
readonly abortSubmission?: (id: SubmissionId) => Promise<'aborted' | 'already_placed' | 'settled' | 'not_found'>;
|
|
50
|
+
/** Refuse an effect call. Reading stays governed by `authenticate` and `project`. */
|
|
51
|
+
readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
|
|
52
|
+
}
|
|
53
|
+
export interface ChatTransportOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Authenticate and select the native conversation in trusted host code. Null refuses. The host also enforces its
|
|
56
|
+
* Origin/CSRF policy here; this handler only requires `application/json` on every POST, which a cross-site form
|
|
57
|
+
* cannot send without a preflight.
|
|
58
|
+
*/
|
|
59
|
+
readonly authenticate: (request: Request) => Promise<ChatTransportAccess | null>;
|
|
60
|
+
/**
|
|
61
|
+
* While the watch stream has nothing to send, write `{"kind":"heartbeat","intervalMs":N}` every N milliseconds (default
|
|
62
|
+
* `CHAT_TRANSPORT.heartbeatMs`, 15 s; 0 turns it off). Proxies and load balancers close a response that stays silent past
|
|
63
|
+
* their idle timeout (an AWS ALB after 60 s by default); the browser client ignores the frame for the transcript and treats a
|
|
64
|
+
* stream silent for 2.5 intervals as lost, then reopens it. The first heartbeat follows the first view, announcing the interval.
|
|
65
|
+
*/
|
|
66
|
+
readonly heartbeatMs?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Authenticated Fetch adapter over one native conversation: watch, submit, abort, configure, submission lookup and history.
|
|
70
|
+
* It adds no execution lifecycle. A closed connection says nothing about the native task.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createChatTransportHandler(options: ChatTransportOptions): (request: Request) => Promise<Response>;
|
|
73
|
+
/**
|
|
74
|
+
* A view of a native conversation whose `submit` first hands the input to a host admission function (for example Cloudflare's
|
|
75
|
+
* `PiHarness.submit`, which schedules the wake job that restarts an evicted Durable Object) and then answers with the native
|
|
76
|
+
* submission, found by its request ID. Every other member is the native conversation, bound to it. The admission must be
|
|
77
|
+
* idempotent on `requestId`, as native submission is.
|
|
78
|
+
*/
|
|
79
|
+
export declare function routeSubmissions(conversation: Conversation, admit: (draft: InputSubmissionDraft, context: Context) => Promise<unknown>): Conversation;
|
|
80
|
+
//# sourceMappingURL=chat-transport.d.ts.map
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
|
|
2
|
+
/** Wire protocol shared with the browser client in `@hachej/boring-ui-kit/remote-chat`. */
|
|
3
|
+
export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
|
|
4
|
+
/** `no-transform` keeps proxies from compressing or rewriting responses; the watch stream adds `x-accel-buffering: no` so nginx does not buffer it. */
|
|
5
|
+
const headers = { 'cache-control': 'no-store, no-transform', 'x-content-type-options': 'nosniff' };
|
|
6
|
+
const failure = (status, reason) => Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason }, { status, headers });
|
|
7
|
+
const json = (value) => Response.json(value, { headers });
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
function content(value) {
|
|
10
|
+
if (typeof value === 'string')
|
|
11
|
+
return value.length > 0;
|
|
12
|
+
return Array.isArray(value) && value.length > 0 && value.every(part => part !== null && typeof part === 'object'
|
|
13
|
+
&& ((part.type === 'text' && typeof part.text === 'string') || (part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string')));
|
|
14
|
+
}
|
|
15
|
+
async function body(request) {
|
|
16
|
+
const value = await readJsonBody(request, CHAT_TRANSPORT.maxRequestBytes, request.signal);
|
|
17
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
18
|
+
throw new RequestGuardError(400, 'Expected a JSON object');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The only way conversation data other than the watch leaves this handler. With `project` set, history entries are
|
|
23
|
+
* projected and a submission record loses its free-form `detail`; `npm run check` fails if the native history read or
|
|
24
|
+
* submission lookup is called anywhere else in this file.
|
|
25
|
+
*/
|
|
26
|
+
function reads(conversation, context, project) {
|
|
27
|
+
return {
|
|
28
|
+
async entries(limit, cursor) {
|
|
29
|
+
const page = await conversation.entries({}, limit, cursor, context);
|
|
30
|
+
if (!project)
|
|
31
|
+
return page;
|
|
32
|
+
const record = await conversation.commit(tx => tx.conversation(conversation.id), context);
|
|
33
|
+
if (!record)
|
|
34
|
+
throw new Error('The conversation is not available');
|
|
35
|
+
let items;
|
|
36
|
+
try {
|
|
37
|
+
items = project({ conversation: record, entries: page.items, docs: {} }).entries;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error('projection_failed');
|
|
41
|
+
}
|
|
42
|
+
return { ...page, items };
|
|
43
|
+
},
|
|
44
|
+
async submission(requestId) {
|
|
45
|
+
const record = await conversation.commit(tx => tx.submissionByRequest(conversation.id, requestId), context);
|
|
46
|
+
if (!record)
|
|
47
|
+
return null;
|
|
48
|
+
if (!project || record.detail === undefined)
|
|
49
|
+
return record;
|
|
50
|
+
const { detail: _detail, ...rest } = record;
|
|
51
|
+
return rest;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Stream newline-delimited JSON. A trusted viewer receives one view and then the exact native operation batches,
|
|
57
|
+
* replayed in the browser with `@earendil-works/chord/delta`. With `project`, raw operations never leave the host:
|
|
58
|
+
* the viewer receives complete projected views, and a slow reader gets the latest one rather than a backlog.
|
|
59
|
+
*/
|
|
60
|
+
function stream(watch, project, signal, heartbeatMs) {
|
|
61
|
+
let pending = [{ kind: 'view', view: watch.value }], wake, end, finished = false;
|
|
62
|
+
// `beat` asks the next pull for a heartbeat. The timer restarts after every frame, so it fires only on an idle stream, and it is
|
|
63
|
+
// cleared when the stream finishes or is cancelled: nothing outlives the response.
|
|
64
|
+
let beat = false, timer;
|
|
65
|
+
const notify = () => { const resume = wake; wake = undefined; resume?.(); };
|
|
66
|
+
const idle = () => { clearTimeout(timer); timer = undefined; if (heartbeatMs > 0 && !finished)
|
|
67
|
+
timer = setTimeout(() => { beat = true; notify(); }, heartbeatMs); };
|
|
68
|
+
const heartbeat = () => encoder.encode(JSON.stringify({ kind: 'heartbeat', intervalMs: heartbeatMs }) + '\n');
|
|
69
|
+
const stop = () => { pending = []; clearTimeout(timer); notify(); void watch.stop(); };
|
|
70
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
71
|
+
watch.start(async (view, ops) => {
|
|
72
|
+
if (signal.aborted)
|
|
73
|
+
return;
|
|
74
|
+
if (project || pending.length >= CHAT_TRANSPORT.maxPendingFrames)
|
|
75
|
+
pending = [{ kind: 'view', view }];
|
|
76
|
+
else
|
|
77
|
+
pending.push({ kind: 'ops', ops });
|
|
78
|
+
notify();
|
|
79
|
+
});
|
|
80
|
+
void watch.closed.then(result => { end = result; signal.removeEventListener('abort', stop); notify(); });
|
|
81
|
+
let started = false;
|
|
82
|
+
const frames = new ReadableStream({
|
|
83
|
+
async pull(controller) {
|
|
84
|
+
while (pending.length === 0 && !beat && end === undefined && !signal.aborted)
|
|
85
|
+
await new Promise(resolve => { wake = resolve; });
|
|
86
|
+
if (finished)
|
|
87
|
+
return;
|
|
88
|
+
const close = (reason) => {
|
|
89
|
+
finished = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
controller.enqueue(encoder.encode(JSON.stringify({ kind: 'end', reason }) + '\n'));
|
|
92
|
+
controller.close();
|
|
93
|
+
};
|
|
94
|
+
if (signal.aborted) {
|
|
95
|
+
close('revoked');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const frame = pending.shift();
|
|
99
|
+
if (frame) {
|
|
100
|
+
let line;
|
|
101
|
+
try {
|
|
102
|
+
line = JSON.stringify(frame.kind === 'view' && project ? { kind: 'view', view: project(frame.view) } : frame);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
finished = true;
|
|
106
|
+
stop();
|
|
107
|
+
controller.enqueue(encoder.encode(JSON.stringify({ kind: 'end', reason: 'projection_failed' }) + '\n'));
|
|
108
|
+
controller.close();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (signal.aborted) {
|
|
112
|
+
close('revoked');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
controller.enqueue(encoder.encode(line + '\n'));
|
|
116
|
+
// The first frame is followed by a heartbeat at once, so the client learns the interval before any idle period.
|
|
117
|
+
if (!started && heartbeatMs > 0) {
|
|
118
|
+
started = true;
|
|
119
|
+
controller.enqueue(heartbeat());
|
|
120
|
+
}
|
|
121
|
+
beat = false;
|
|
122
|
+
idle();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (beat && end === undefined) {
|
|
126
|
+
beat = false;
|
|
127
|
+
controller.enqueue(heartbeat());
|
|
128
|
+
idle();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
finished = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
controller.enqueue(encoder.encode(JSON.stringify({ kind: 'end', reason: end.reason }) + '\n'));
|
|
134
|
+
controller.close();
|
|
135
|
+
},
|
|
136
|
+
cancel() { finished = true; stop(); },
|
|
137
|
+
});
|
|
138
|
+
return new Response(frames, { headers: { ...headers, 'content-type': 'application/x-ndjson; charset=utf-8', 'x-accel-buffering': 'no' } });
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Authenticated Fetch adapter over one native conversation: watch, submit, abort, configure, submission lookup and history.
|
|
142
|
+
* It adds no execution lifecycle. A closed connection says nothing about the native task.
|
|
143
|
+
*/
|
|
144
|
+
export function createChatTransportHandler(options) {
|
|
145
|
+
return async (request) => {
|
|
146
|
+
const operation = new URL(request.url).searchParams.get('op');
|
|
147
|
+
if (request.method === 'POST' && !hasJsonContentType(request.headers))
|
|
148
|
+
return failure(415, 'unsupported-media-type');
|
|
149
|
+
let access;
|
|
150
|
+
try {
|
|
151
|
+
access = await options.authenticate(request);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return failure(503, 'authentication-unavailable');
|
|
155
|
+
}
|
|
156
|
+
if (!access)
|
|
157
|
+
return failure(401, 'authentication-required');
|
|
158
|
+
const { conversation, context } = access;
|
|
159
|
+
if (access.revoked?.aborted)
|
|
160
|
+
return failure(403, 'not-authorized');
|
|
161
|
+
const read = reads(conversation, context, access.project);
|
|
162
|
+
const allowed = async (effect) => {
|
|
163
|
+
if (access.revoked?.aborted)
|
|
164
|
+
return false;
|
|
165
|
+
const granted = access.allow === undefined || await access.allow(effect) === true;
|
|
166
|
+
return granted && !access.revoked?.aborted;
|
|
167
|
+
};
|
|
168
|
+
const params = new URL(request.url).searchParams;
|
|
169
|
+
try {
|
|
170
|
+
if (request.method === 'GET' && operation === 'watch') {
|
|
171
|
+
const signal = access.revoked ? AbortSignal.any([request.signal, access.revoked]) : request.signal;
|
|
172
|
+
const watch = await conversation.watch(context);
|
|
173
|
+
if (signal.aborted) {
|
|
174
|
+
await watch.stop();
|
|
175
|
+
return failure(403, 'not-authorized');
|
|
176
|
+
}
|
|
177
|
+
return stream(watch, access.project, signal, options.heartbeatMs ?? CHAT_TRANSPORT.heartbeatMs);
|
|
178
|
+
}
|
|
179
|
+
if (request.method === 'GET' && operation === 'submission') {
|
|
180
|
+
const requestId = params.get('requestId');
|
|
181
|
+
if (!requestId)
|
|
182
|
+
return failure(400, 'invalid-request');
|
|
183
|
+
const record = await read.submission(requestId);
|
|
184
|
+
if (access.revoked?.aborted)
|
|
185
|
+
return failure(403, 'not-authorized');
|
|
186
|
+
return json({ record });
|
|
187
|
+
}
|
|
188
|
+
if (request.method === 'GET' && operation === 'entries') {
|
|
189
|
+
const limit = Number(params.get('limit'));
|
|
190
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > CHAT_TRANSPORT.maxHistoryPage)
|
|
191
|
+
return failure(400, 'invalid-request');
|
|
192
|
+
let cursor;
|
|
193
|
+
const encoded = params.get('cursor');
|
|
194
|
+
if (encoded !== null) {
|
|
195
|
+
try {
|
|
196
|
+
cursor = JSON.parse(encoded);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return failure(400, 'invalid-request');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const page = await read.entries(limit, cursor);
|
|
203
|
+
if (access.revoked?.aborted)
|
|
204
|
+
return failure(403, 'not-authorized');
|
|
205
|
+
return json({ page });
|
|
206
|
+
}
|
|
207
|
+
if (request.method === 'POST' && operation === 'submit') {
|
|
208
|
+
const input = await body(request);
|
|
209
|
+
if (typeof input['requestId'] !== 'string' || !input['requestId'] || !content(input['content'])
|
|
210
|
+
|| (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string'))
|
|
211
|
+
return failure(400, 'invalid-request');
|
|
212
|
+
let prepared = input['content'];
|
|
213
|
+
if (access.prepareInput) {
|
|
214
|
+
try {
|
|
215
|
+
prepared = await access.prepareInput(prepared);
|
|
216
|
+
}
|
|
217
|
+
catch { /* submit what the person typed */ }
|
|
218
|
+
}
|
|
219
|
+
if (!await allowed('submit') || access.revoked?.aborted)
|
|
220
|
+
return failure(403, 'not-authorized');
|
|
221
|
+
const submission = await conversation.submit({ type: 'input', requestId: input['requestId'], content: prepared,
|
|
222
|
+
...(input['whenBusy'] === undefined ? {} : { whenBusy: input['whenBusy'] }) }, context);
|
|
223
|
+
return json({ submissionId: submission.id });
|
|
224
|
+
}
|
|
225
|
+
if (request.method === 'POST' && operation === 'abort') {
|
|
226
|
+
if (!await allowed('abort') || access.revoked?.aborted)
|
|
227
|
+
return failure(403, 'not-authorized');
|
|
228
|
+
// The body carries nothing, but it is read under the shared guard before answering: on Workers a body still unread
|
|
229
|
+
// when the response is sent makes the runtime throw "Can't read from request stream after response has been sent".
|
|
230
|
+
await body(request).catch(() => undefined);
|
|
231
|
+
await conversation.abort(context);
|
|
232
|
+
return json({ aborted: true });
|
|
233
|
+
}
|
|
234
|
+
if (request.method === 'POST' && operation === 'answer') {
|
|
235
|
+
if (!access.answer)
|
|
236
|
+
return failure(404, 'not-supported');
|
|
237
|
+
const input = await body(request);
|
|
238
|
+
if (typeof input['callId'] !== 'string' || !input['callId'] || typeof input['answer'] !== 'string')
|
|
239
|
+
return failure(400, 'invalid-request');
|
|
240
|
+
if (!await allowed('answer'))
|
|
241
|
+
return failure(403, 'not-authorized');
|
|
242
|
+
return json(await access.answer(input['callId'], input['answer']));
|
|
243
|
+
}
|
|
244
|
+
if (request.method === 'POST' && operation === 'configure') {
|
|
245
|
+
if (!access.configure)
|
|
246
|
+
return failure(404, 'not-supported');
|
|
247
|
+
const input = await body(request);
|
|
248
|
+
const model = input['model'], level = input['thinkingLevel'];
|
|
249
|
+
const validModel = model === undefined || (model !== null && typeof model === 'object' && !Array.isArray(model)
|
|
250
|
+
&& typeof model['provider'] === 'string' && typeof model['modelId'] === 'string');
|
|
251
|
+
if (!validModel || (level !== undefined && typeof level !== 'string') || (model === undefined && level === undefined))
|
|
252
|
+
return failure(400, 'invalid-request');
|
|
253
|
+
const { provider, modelId } = (model ?? {});
|
|
254
|
+
if (!await allowed('configure') || access.revoked?.aborted)
|
|
255
|
+
return failure(403, 'not-authorized');
|
|
256
|
+
return json(await access.configure({ ...(model === undefined ? {} : { model: { provider: provider, modelId: modelId } }), ...(level === undefined ? {} : { thinkingLevel: level }) }));
|
|
257
|
+
}
|
|
258
|
+
if (request.method === 'POST' && operation === 'withdraw') {
|
|
259
|
+
if (!access.abortSubmission)
|
|
260
|
+
return failure(404, 'not-supported');
|
|
261
|
+
const id = (await body(request))['submissionId'];
|
|
262
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
263
|
+
return failure(400, 'invalid-request');
|
|
264
|
+
if (!await allowed('withdraw'))
|
|
265
|
+
return failure(403, 'not-authorized');
|
|
266
|
+
// The native operation scopes the id to this conversation; an unknown or foreign id is "too late" to the caller.
|
|
267
|
+
const result = await access.abortSubmission(id);
|
|
268
|
+
return json({ result: result === 'not_found' ? 'settled' : result });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof RequestGuardError)
|
|
273
|
+
return failure(error.status, error.status === 413 ? 'request-too-large' : error.status === 415 ? 'unsupported-media-type' : 'invalid-request');
|
|
274
|
+
// A host admission refused the input before it reached the conversation (for example `MeteringRefused` from `@hachej/boring-agent/metering`).
|
|
275
|
+
if (error instanceof Error && error.code === 'submission-refused') {
|
|
276
|
+
return Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason: 'submission-refused', message: error.message.slice(0, 500) }, { status: 402, headers });
|
|
277
|
+
}
|
|
278
|
+
return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
|
|
279
|
+
}
|
|
280
|
+
return failure(404, 'unknown-operation');
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* A view of a native conversation whose `submit` first hands the input to a host admission function (for example Cloudflare's
|
|
285
|
+
* `PiHarness.submit`, which schedules the wake job that restarts an evicted Durable Object) and then answers with the native
|
|
286
|
+
* submission, found by its request ID. Every other member is the native conversation, bound to it. The admission must be
|
|
287
|
+
* idempotent on `requestId`, as native submission is.
|
|
288
|
+
*/
|
|
289
|
+
export function routeSubmissions(conversation, admit) {
|
|
290
|
+
return new Proxy(conversation, { get: (target, key) => {
|
|
291
|
+
if (key === 'submit') {
|
|
292
|
+
return async (draft, context) => {
|
|
293
|
+
const requestId = draft.requestId;
|
|
294
|
+
if (!requestId)
|
|
295
|
+
throw new TypeError('A request ID is required');
|
|
296
|
+
await admit(draft, context);
|
|
297
|
+
const record = await target.commit(tx => tx.submissionByRequest(target.id, requestId), context);
|
|
298
|
+
if (!record)
|
|
299
|
+
throw new Error('The submission was not recorded');
|
|
300
|
+
return { id: record.id };
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const value = Reflect.get(target, key, target);
|
|
304
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
305
|
+
} });
|
|
306
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Harness, ConversationId, TaskId } from '@earendil-works/pi-durable';
|
|
2
|
+
/** Borrow only the native handle. A second independently supplied Registry
|
|
3
|
+
* cannot prove it belongs to this Harness. The host installs native features
|
|
4
|
+
* explicitly through its own registry; passive attachment needs no registry.
|
|
5
|
+
*/
|
|
6
|
+
export interface AttachmentInput<NativeHarness extends Harness = Harness> {
|
|
7
|
+
readonly harness: NativeHarness;
|
|
8
|
+
}
|
|
9
|
+
export interface BorrowedAttachment<NativeHarness extends Harness = Harness> {
|
|
10
|
+
readonly harness: NativeHarness;
|
|
11
|
+
readonly detach: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** Host services keep their own APIs. Native defineTool/ToolRegistration,
|
|
14
|
+
* ToolExecutionApi and ToolExecutionResult are the tool contract; no parallel
|
|
15
|
+
* Boring HostOperation, RuntimeSchema or reduced execution-result envelope.
|
|
16
|
+
*/
|
|
17
|
+
export interface RuntimeQuestionRef {
|
|
18
|
+
readonly runtimeId: string;
|
|
19
|
+
readonly conversationId: ConversationId;
|
|
20
|
+
readonly taskId: TaskId;
|
|
21
|
+
readonly questionId: string;
|
|
22
|
+
readonly scopeId: string;
|
|
23
|
+
readonly subjectDigest: string;
|
|
24
|
+
readonly policyVersion: string;
|
|
25
|
+
readonly expiresAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface QuestionResolution<Answer> {
|
|
28
|
+
readonly question: RuntimeQuestionRef;
|
|
29
|
+
readonly resolutionId: string;
|
|
30
|
+
readonly answer: Answer;
|
|
31
|
+
}
|
|
32
|
+
/** References to native tasks, never a second job model. Preserve result type. */
|
|
33
|
+
export interface DeliveryBinding<Result = unknown> {
|
|
34
|
+
readonly producer: TaskId<Result>;
|
|
35
|
+
readonly delivery: TaskId;
|
|
36
|
+
readonly operationId: string;
|
|
37
|
+
readonly inputDigest: string;
|
|
38
|
+
readonly recoverableInputRef: string;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=contracts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|