@hachej/boring-agent 0.1.111 → 0.2.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/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 +171 -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,71 @@
|
|
|
1
|
+
import type { ConversationId, Extension, ToolRegistration } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { JsonValue } from '@earendil-works/chord';
|
|
3
|
+
import type { Static, TSchema } from '@earendil-works/pi-ai';
|
|
4
|
+
/** The two answers an approval question accepts. A chat answers with exactly these strings through `answerUserQuestion`. */
|
|
5
|
+
export declare const APPROVE = "Approve";
|
|
6
|
+
export declare const DENY = "Deny";
|
|
7
|
+
/** A denied call's result text starts with this, so a viewer can tell a denial from a failure. */
|
|
8
|
+
export declare const DENIED_PREFIX = "Denied by the person.";
|
|
9
|
+
/** A call whose question was cancelled (the run was stopped) before the person answered: it did not run. */
|
|
10
|
+
export declare const CANCELLED_PREFIX = "Not run: the approval question was cancelled.";
|
|
11
|
+
/**
|
|
12
|
+
* Key under a gated call's running details (then, unless the tool returns its own `details`, its result details) that holds
|
|
13
|
+
* `{ summary, decision? }`: the one-line text `summarize` returned and, once made, `approved`, `denied` or `cancelled`. A chat view carries it, so an approval card can show the summary
|
|
14
|
+
* without reading the question document. The card in `registry/pi-chat` repeats this key (a contract test keeps them equal).
|
|
15
|
+
*/
|
|
16
|
+
export declare const APPROVAL_DETAILS = "boring.approval";
|
|
17
|
+
/** The call a rule is asked about: which conversation it runs in (a host can name the site or project it maps to) and its ID. */
|
|
18
|
+
export interface ApprovalCall {
|
|
19
|
+
readonly conversationId: ConversationId;
|
|
20
|
+
readonly callId: string;
|
|
21
|
+
readonly toolName: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RequireApprovalOptions<TParameters extends TSchema = TSchema> {
|
|
24
|
+
/** One line about what the call would change, from its arguments. It is stored with the question; the model never writes it. */
|
|
25
|
+
readonly summarize: (args: Static<TParameters>, call: ApprovalCall) => string;
|
|
26
|
+
/** Ask only when this returns true (for example any HTTP method but GET); other calls run at once. Default: always ask. */
|
|
27
|
+
readonly when?: (args: Static<TParameters>, call: ApprovalCall) => boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a native tool so the person must approve each call before it executes. The tool keeps its name, schema and
|
|
31
|
+
* description. Before `execute`, the call asks "Approve / Deny" through the ask-user mechanism (a conversation document
|
|
32
|
+
* keyed by the tool call ID, answered with `answerUserQuestion` over the chat transport's `answer` operation, shown by
|
|
33
|
+
* pi-chat's approval card). The check runs in host code inside the tool's own execution, so the model cannot skip it.
|
|
34
|
+
*
|
|
35
|
+
* - While it waits, the summary is published as running details under `APPROVAL_DETAILS` for the card.
|
|
36
|
+
* - Deny returns an error result starting `DENIED_PREFIX` and `execute` never runs. A question cancelled while the call
|
|
37
|
+
* itself keeps running returns one starting `CANCELLED_PREFIX`; when the call is being stopped, Pi records the stop.
|
|
38
|
+
* - A pending approval survives a restart: the wrapper is replay-safe, and replay re-attaches to the stored question.
|
|
39
|
+
* - Execution happens at most once per call. The wrapper retains a native memo before running the inner tool; if the
|
|
40
|
+
* process dies after that and the inner tool is not itself `replay: 'safe'`, recovery returns an error saying the
|
|
41
|
+
* outcome is unknown instead of repeating a change that may have happened.
|
|
42
|
+
*
|
|
43
|
+
* Code mode: gated tools are native tools. Do not expose them to a code-mode sandbox (its nested calls cannot reach a
|
|
44
|
+
* person), and filter with `isApprovalGated`. Give `run_code` the read tools only; changes go through the gated native tool.
|
|
45
|
+
*/
|
|
46
|
+
export declare function requireApproval<TParameters extends TSchema, TDetails extends JsonValue>(tool: ToolRegistration<TParameters, TDetails>, options: RequireApprovalOptions<TParameters>): ToolRegistration<TParameters, JsonValue>;
|
|
47
|
+
/** Whether `tool` came from `requireApproval`. Use it to keep gated tools out of a code-mode sandbox's tool list. */
|
|
48
|
+
export declare function isApprovalGated(tool: object): boolean;
|
|
49
|
+
/** How one tool is gated by `createApprovalExtension`: `true` uses a default summary and always asks. */
|
|
50
|
+
export type ApprovalRule = true | {
|
|
51
|
+
readonly summarize?: (args: Record<string, unknown>, call: ApprovalCall) => string;
|
|
52
|
+
readonly when?: (args: Record<string, unknown>, call: ApprovalCall) => boolean;
|
|
53
|
+
};
|
|
54
|
+
export interface ApprovalExtensionOptions {
|
|
55
|
+
/** Native extension name. Default `boring.approval`; use another name to compose several rule sets. */
|
|
56
|
+
readonly name?: string;
|
|
57
|
+
/** Tool names to gate, with their rule. Any native tool works, including Pi's stock `bash`, `write` and `edit`. */
|
|
58
|
+
readonly tools: Readonly<Record<string, ApprovalRule>>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The approval gate as an opt-in native extension. It brings no tools: it wraps the named tools (native `wraps`, Pi's own
|
|
62
|
+
* decorator by tool name) with `requireApproval`, so whatever tool of that name wins in a conversation waits for the
|
|
63
|
+
* person there. Pi applies wraps only in conversations that select the extension, so it is opt-in per conversation:
|
|
64
|
+
* `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
|
|
65
|
+
* Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
|
|
66
|
+
*
|
|
67
|
+
* It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
|
|
68
|
+
* not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
|
|
69
|
+
*/
|
|
70
|
+
export declare function createApprovalExtension(options: ApprovalExtensionOptions): Extension;
|
|
71
|
+
//# sourceMappingURL=approval.d.ts.map
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { defineExtension } from '@earendil-works/pi-durable';
|
|
2
|
+
import { askPerson } from './ask-user.js';
|
|
3
|
+
/** The two answers an approval question accepts. A chat answers with exactly these strings through `answerUserQuestion`. */
|
|
4
|
+
export const APPROVE = 'Approve';
|
|
5
|
+
export const DENY = 'Deny';
|
|
6
|
+
/** A denied call's result text starts with this, so a viewer can tell a denial from a failure. */
|
|
7
|
+
export const DENIED_PREFIX = 'Denied by the person.';
|
|
8
|
+
/** A call whose question was cancelled (the run was stopped) before the person answered: it did not run. */
|
|
9
|
+
export const CANCELLED_PREFIX = 'Not run: the approval question was cancelled.';
|
|
10
|
+
/**
|
|
11
|
+
* Key under a gated call's running details (then, unless the tool returns its own `details`, its result details) that holds
|
|
12
|
+
* `{ summary, decision? }`: the one-line text `summarize` returned and, once made, `approved`, `denied` or `cancelled`. A chat view carries it, so an approval card can show the summary
|
|
13
|
+
* without reading the question document. The card in `registry/pi-chat` repeats this key (a contract test keeps them equal).
|
|
14
|
+
*/
|
|
15
|
+
export const APPROVAL_DETAILS = 'boring.approval';
|
|
16
|
+
const MEMO = 'boring.approval.started';
|
|
17
|
+
const GATED = new WeakSet();
|
|
18
|
+
const text = (value, isError) => ({ isError, content: [{ type: 'text', text: value }] });
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a native tool so the person must approve each call before it executes. The tool keeps its name, schema and
|
|
21
|
+
* description. Before `execute`, the call asks "Approve / Deny" through the ask-user mechanism (a conversation document
|
|
22
|
+
* keyed by the tool call ID, answered with `answerUserQuestion` over the chat transport's `answer` operation, shown by
|
|
23
|
+
* pi-chat's approval card). The check runs in host code inside the tool's own execution, so the model cannot skip it.
|
|
24
|
+
*
|
|
25
|
+
* - While it waits, the summary is published as running details under `APPROVAL_DETAILS` for the card.
|
|
26
|
+
* - Deny returns an error result starting `DENIED_PREFIX` and `execute` never runs. A question cancelled while the call
|
|
27
|
+
* itself keeps running returns one starting `CANCELLED_PREFIX`; when the call is being stopped, Pi records the stop.
|
|
28
|
+
* - A pending approval survives a restart: the wrapper is replay-safe, and replay re-attaches to the stored question.
|
|
29
|
+
* - Execution happens at most once per call. The wrapper retains a native memo before running the inner tool; if the
|
|
30
|
+
* process dies after that and the inner tool is not itself `replay: 'safe'`, recovery returns an error saying the
|
|
31
|
+
* outcome is unknown instead of repeating a change that may have happened.
|
|
32
|
+
*
|
|
33
|
+
* Code mode: gated tools are native tools. Do not expose them to a code-mode sandbox (its nested calls cannot reach a
|
|
34
|
+
* person), and filter with `isApprovalGated`. Give `run_code` the read tools only; changes go through the gated native tool.
|
|
35
|
+
*/
|
|
36
|
+
export function requireApproval(tool, options) {
|
|
37
|
+
if (GATED.has(tool))
|
|
38
|
+
return tool; // already gated: never ask twice
|
|
39
|
+
// The gated tool's details are the inner tool's, with the approval record merged in under APPROVAL_DETAILS, so a view keeps the
|
|
40
|
+
// summary and decision even when the inner tool publishes details of its own (non-object details are kept as they are).
|
|
41
|
+
const gated = {
|
|
42
|
+
...tool,
|
|
43
|
+
replay: 'safe',
|
|
44
|
+
execute: async (args, outerApi, context) => {
|
|
45
|
+
let record;
|
|
46
|
+
const withRecord = (value) => !record ? value
|
|
47
|
+
: value === undefined || (value !== null && typeof value === 'object' && !Array.isArray(value)) ? { ...(value ?? {}), [APPROVAL_DETAILS]: record } : value;
|
|
48
|
+
const publish = (next) => { record = next; return outerApi.details(withRecord(undefined), context); };
|
|
49
|
+
const api = { ...outerApi, details: (value, inner) => outerApi.details(withRecord(value), inner) };
|
|
50
|
+
// The wrapper is replay-safe so a pending question survives a restart; an inner tool that is not keeps its
|
|
51
|
+
// at-most-once guarantee through a native memo, whether or not this call asked the person.
|
|
52
|
+
const run = async () => {
|
|
53
|
+
if (tool.replay !== 'safe') {
|
|
54
|
+
if (await outerApi.memo(MEMO, context) !== undefined)
|
|
55
|
+
return text(`${tool.name} started, but the process stopped while it ran. Whether the change was applied is unknown; check before retrying.`, true);
|
|
56
|
+
await outerApi.memo(MEMO, true, context);
|
|
57
|
+
}
|
|
58
|
+
const result = await tool.execute(args, api, context);
|
|
59
|
+
return result.details === undefined ? result : { ...result, details: withRecord(result.details) };
|
|
60
|
+
};
|
|
61
|
+
let ask = true;
|
|
62
|
+
const call = { conversationId: outerApi.conversationId, callId: outerApi.callId, toolName: tool.name };
|
|
63
|
+
try {
|
|
64
|
+
ask = options.when ? options.when(args, call) : true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
ask = true;
|
|
68
|
+
} // a broken rule asks rather than skipping the person
|
|
69
|
+
if (!ask)
|
|
70
|
+
return run();
|
|
71
|
+
let summary;
|
|
72
|
+
try {
|
|
73
|
+
summary = options.summarize(args, call);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
summary = JSON.stringify(args);
|
|
77
|
+
}
|
|
78
|
+
summary = summary.slice(0, 500);
|
|
79
|
+
await publish({ summary });
|
|
80
|
+
const prompt = `Allow ${tool.name}? ${summary}`.slice(0, 1000);
|
|
81
|
+
// The decision joins the summary in the call's details, so a view tells approved, denied and never-answered apart.
|
|
82
|
+
const decided = (decision) => publish({ summary, decision });
|
|
83
|
+
let answer;
|
|
84
|
+
try {
|
|
85
|
+
answer = await askPerson(outerApi, context, { prompt, options: [APPROVE, DENY] });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (context.abortSignal?.aborted)
|
|
89
|
+
throw error; // the call itself is being stopped: Pi records that, and no decision was made
|
|
90
|
+
await decided('cancelled');
|
|
91
|
+
return text(`${CANCELLED_PREFIX} ${tool.name} was not run and nothing was changed.`, true);
|
|
92
|
+
}
|
|
93
|
+
if (answer !== APPROVE) {
|
|
94
|
+
await decided('denied');
|
|
95
|
+
return text(`${DENIED_PREFIX} ${tool.name} was not run and nothing was changed.`, true);
|
|
96
|
+
}
|
|
97
|
+
await decided('approved');
|
|
98
|
+
return run();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
GATED.add(gated);
|
|
102
|
+
return gated;
|
|
103
|
+
}
|
|
104
|
+
/** Whether `tool` came from `requireApproval`. Use it to keep gated tools out of a code-mode sandbox's tool list. */
|
|
105
|
+
export function isApprovalGated(tool) { return GATED.has(tool); }
|
|
106
|
+
const defaultSummary = (name) => (args) => `${name} ${JSON.stringify(args)}`.slice(0, 300);
|
|
107
|
+
/**
|
|
108
|
+
* The approval gate as an opt-in native extension. It brings no tools: it wraps the named tools (native `wraps`, Pi's own
|
|
109
|
+
* decorator by tool name) with `requireApproval`, so whatever tool of that name wins in a conversation waits for the
|
|
110
|
+
* person there. Pi applies wraps only in conversations that select the extension, so it is opt-in per conversation:
|
|
111
|
+
* `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
|
|
112
|
+
* Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
|
|
113
|
+
*
|
|
114
|
+
* It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
|
|
115
|
+
* not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
|
|
116
|
+
*/
|
|
117
|
+
export function createApprovalExtension(options) {
|
|
118
|
+
const wraps = Object.entries(options.tools).map(([name, rule]) => ({
|
|
119
|
+
tool: name,
|
|
120
|
+
wrap: (tool) => requireApproval(tool, {
|
|
121
|
+
summarize: (rule !== true && rule.summarize) || defaultSummary(name),
|
|
122
|
+
...(rule !== true && rule.when ? { when: rule.when } : {}),
|
|
123
|
+
}),
|
|
124
|
+
}));
|
|
125
|
+
return defineExtension({ name: options.name ?? 'boring.approval', wraps });
|
|
126
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ToolExecutionApi } from '@earendil-works/pi-durable';
|
|
2
|
+
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
+
import type { Context } from '@earendil-works/chord';
|
|
4
|
+
import type { ResourceAccess, ResourceLocator } from '@hachej/boring-files';
|
|
5
|
+
import type { WorkspaceBinding, WorkspaceResolver } from './workspaces.js';
|
|
6
|
+
export declare const ARTIFACT_TYPES: readonly ["markdown", "html", "svg", "code", "canvas"];
|
|
7
|
+
export type ArtifactType = typeof ARTIFACT_TYPES[number];
|
|
8
|
+
/**
|
|
9
|
+
* What a tool result carries under its `artifact` key. The descriptor of `present` points at a workspace file (`target`), names the
|
|
10
|
+
* revision shown and carries no `id` or `ordinal` (both stay optional for hosts that number their own versions).
|
|
11
|
+
*/
|
|
12
|
+
export interface ArtifactDescriptor {
|
|
13
|
+
readonly schema: 'boring.artifact';
|
|
14
|
+
readonly version: 1;
|
|
15
|
+
readonly id?: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly type: ArtifactType;
|
|
18
|
+
readonly mediaType: string;
|
|
19
|
+
readonly language?: string;
|
|
20
|
+
readonly target: ResourceLocator;
|
|
21
|
+
readonly revision: string;
|
|
22
|
+
readonly ordinal?: number;
|
|
23
|
+
}
|
|
24
|
+
export type ArtifactDescriptorInput = Omit<ArtifactDescriptor, 'schema' | 'version'>;
|
|
25
|
+
/** Build a descriptor, refusing anything `parseArtifact` would refuse. */
|
|
26
|
+
export declare function createArtifactDescriptor(input: ArtifactDescriptorInput): ArtifactDescriptor;
|
|
27
|
+
/**
|
|
28
|
+
* Strict shape check of an untrusted value (a model-visible tool result, a stored reference). Returns a fresh copy or
|
|
29
|
+
* undefined. This function has no Pi runtime dependency; `registry/pi-chat/artifact.ts` carries the same validator for
|
|
30
|
+
* the browser, because a copied registry item cannot import this package.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseArtifact(value: unknown): ArtifactDescriptor | undefined;
|
|
33
|
+
export interface PresentToolOptions {
|
|
34
|
+
/**
|
|
35
|
+
* The workspace of each call, resolved like Pi's environment (`@hachej/boring-agent/workspaces`), or one binding `{ files, root }`; absent: the
|
|
36
|
+
* workspace attached to the call's env (`withWorkspace`). Its
|
|
37
|
+
* provider (`@hachej/boring-files/workspace`) reads the file and retains the presented revision in its history; the descriptor names its
|
|
38
|
+
* provider id.
|
|
39
|
+
*/
|
|
40
|
+
readonly workspace?: WorkspaceResolver | WorkspaceBinding | undefined;
|
|
41
|
+
/** The agent's principal for the provider. Default: the binding's `access`. */
|
|
42
|
+
readonly resolveAccess?: (api: ToolExecutionApi, context: Context) => ResourceAccess | Promise<ResourceAccess>;
|
|
43
|
+
/** Largest file shown, in bytes. Defaults to 256 KiB. */
|
|
44
|
+
readonly maxBytes?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `present(path)`: show a workspace file to the person as a card and a side panel. The agent writes the file with its ordinary file
|
|
48
|
+
* tools first. The card points at the file; the file's history (the provider's) is its list of versions. Nothing is written here.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createPresentTool(options?: PresentToolOptions): import("@earendil-works/pi-durable").ToolRegistration<Type.TObject<{
|
|
51
|
+
path: Type.TString;
|
|
52
|
+
}>, import("@earendil-works/chord").JsonValue>;
|
|
53
|
+
//# sourceMappingURL=artifacts.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { defineTool } from '@earendil-works/pi-durable';
|
|
2
|
+
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
+
import { asWorkspaceResolver, workspaceFor } from './workspaces.js';
|
|
4
|
+
/*
|
|
5
|
+
* Artifacts: substantial, self-contained content (a document, an HTML page, an SVG, code) that a chat shows as a card
|
|
6
|
+
* and a side panel instead of a wall of text. An artifact is a file of the workspace: the agent writes it with its ordinary
|
|
7
|
+
* file tools and calls `present(path)`, which keeps the shown revision in the workspace provider's history and returns a
|
|
8
|
+
* descriptor pointing at the file. There is no store of artifacts and no index. Nothing is executed here.
|
|
9
|
+
*/
|
|
10
|
+
export const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'];
|
|
11
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
12
|
+
const LANGUAGE = /^[A-Za-z0-9+#._-]{1,32}$/;
|
|
13
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
const hasOnly = (value, keys) => Object.keys(value).every(key => keys.includes(key));
|
|
15
|
+
/** Build a descriptor, refusing anything `parseArtifact` would refuse. */
|
|
16
|
+
export function createArtifactDescriptor(input) {
|
|
17
|
+
const descriptor = { schema: 'boring.artifact', version: 1, ...input };
|
|
18
|
+
const parsed = parseArtifact(descriptor);
|
|
19
|
+
if (!parsed)
|
|
20
|
+
throw new TypeError('Invalid artifact descriptor');
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Strict shape check of an untrusted value (a model-visible tool result, a stored reference). Returns a fresh copy or
|
|
25
|
+
* undefined. This function has no Pi runtime dependency; `registry/pi-chat/artifact.ts` carries the same validator for
|
|
26
|
+
* the browser, because a copied registry item cannot import this package.
|
|
27
|
+
*/
|
|
28
|
+
export function parseArtifact(value) {
|
|
29
|
+
if (!isObject(value) || !hasOnly(value, ['schema', 'version', 'id', 'title', 'type', 'mediaType', 'language', 'target', 'revision', 'ordinal']))
|
|
30
|
+
return undefined;
|
|
31
|
+
const { id, title, type, mediaType, language, target, revision, ordinal } = value;
|
|
32
|
+
if (value['schema'] !== 'boring.artifact' || value['version'] !== 1)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (id !== undefined && (typeof id !== 'string' || !ID.test(id)))
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof title !== 'string' || !title.trim() || title.length > 200)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (typeof type !== 'string' || !ARTIFACT_TYPES.includes(type))
|
|
39
|
+
return undefined;
|
|
40
|
+
if (typeof mediaType !== 'string' || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(mediaType))
|
|
41
|
+
return undefined;
|
|
42
|
+
if (language !== undefined && (typeof language !== 'string' || !LANGUAGE.test(language)))
|
|
43
|
+
return undefined;
|
|
44
|
+
if (typeof revision !== 'string' || !revision || revision.length > 256)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (ordinal !== undefined && (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1))
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!isObject(target) || !hasOnly(target, ['resource', 'view']) || !isObject(target['resource']) || !hasOnly(target['resource'], ['providerId', 'path']))
|
|
49
|
+
return undefined;
|
|
50
|
+
const { providerId, path } = target['resource'];
|
|
51
|
+
if (typeof providerId !== 'string' || !providerId || typeof path !== 'string' || !path || path.length > 512)
|
|
52
|
+
return undefined;
|
|
53
|
+
const view = target['view'];
|
|
54
|
+
if (!isObject(view))
|
|
55
|
+
return undefined;
|
|
56
|
+
let parsedView;
|
|
57
|
+
if (view['kind'] === 'published' && hasOnly(view, ['kind']))
|
|
58
|
+
parsedView = { kind: 'published' };
|
|
59
|
+
else if (view['kind'] === 'working' && hasOnly(view, ['kind', 'viewId']) && typeof view['viewId'] === 'string' && view['viewId'])
|
|
60
|
+
parsedView = { kind: 'working', viewId: view['viewId'] };
|
|
61
|
+
else
|
|
62
|
+
return undefined;
|
|
63
|
+
return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type, mediaType, ...(language === undefined ? {} : { language: language }),
|
|
64
|
+
target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };
|
|
65
|
+
}
|
|
66
|
+
const reply = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
|
|
67
|
+
const decode = (bytes) => { try {
|
|
68
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
} };
|
|
73
|
+
const PRESENTED = {
|
|
74
|
+
md: { type: 'markdown', mediaType: 'text/markdown' }, markdown: { type: 'markdown', mediaType: 'text/markdown' },
|
|
75
|
+
html: { type: 'html', mediaType: 'text/html' }, htm: { type: 'html', mediaType: 'text/html' },
|
|
76
|
+
svg: { type: 'svg', mediaType: 'image/svg+xml' }, tldraw: { type: 'canvas', mediaType: 'application/vnd.tldraw+json' },
|
|
77
|
+
};
|
|
78
|
+
const LANGUAGE_OF = { js: 'javascript', mjs: 'javascript', ts: 'typescript', py: 'python', json: 'json', css: 'css', sql: 'sql', yaml: 'yaml', yml: 'yaml', sh: 'shell', rs: 'rust', go: 'go', java: 'java', csv: 'csv' };
|
|
79
|
+
/**
|
|
80
|
+
* `present(path)`: show a workspace file to the person as a card and a side panel. The agent writes the file with its ordinary file
|
|
81
|
+
* tools first. The card points at the file; the file's history (the provider's) is its list of versions. Nothing is written here.
|
|
82
|
+
*/
|
|
83
|
+
export function createPresentTool(options = {}) {
|
|
84
|
+
const resolver = asWorkspaceResolver(options.workspace);
|
|
85
|
+
const maxBytes = options.maxBytes ?? 256 * 1024;
|
|
86
|
+
return defineTool({
|
|
87
|
+
name: 'present',
|
|
88
|
+
// How to use it travels with the tool, so every host that offers `present` gets the same rules without repeating them in its prompt.
|
|
89
|
+
description: [
|
|
90
|
+
'Show a file of the workspace to the person in a side panel instead of pasting it in your reply.',
|
|
91
|
+
'When the person asks for something substantial and self-contained (a report, document, HTML page, SVG image or program), write it to a file with the write tool, using a short descriptive path with the right extension (.md, .html, .svg with a viewBox, or the language\'s own), then call present with that path; never put that content in your reply. After present, reply with one or two short sentences and never repeat the content.',
|
|
92
|
+
'Short answers stay in the chat, and so does anything the person asks to have in the chat or without tools or artifacts: then call no tool.',
|
|
93
|
+
'To change, extend or fix something you presented: read the file again first (the person may have edited it; keep their changes), change it with edit (or write the whole new content) at the same path, never a second file, and call present again. If you do not remember the path, list the files.',
|
|
94
|
+
].join(' '),
|
|
95
|
+
parameters: Type.Object({ path: Type.String({ minLength: 1, maxLength: 512, description: 'The file path, relative to the workspace.' }) }, { additionalProperties: false }),
|
|
96
|
+
replay: 'safe',
|
|
97
|
+
execute: async (args, api, context) => {
|
|
98
|
+
const path = args.path.replace(/^\.\//, '');
|
|
99
|
+
const resolved = await workspaceFor(resolver, api, context);
|
|
100
|
+
if ('refused' in resolved)
|
|
101
|
+
return reply({ kind: 'denied', reason: resolved.refused });
|
|
102
|
+
const { files } = resolved.binding;
|
|
103
|
+
const providerId = files.providerId;
|
|
104
|
+
const granted = options.resolveAccess ? await options.resolveAccess(api, context) : resolved.binding.access;
|
|
105
|
+
if (granted === undefined)
|
|
106
|
+
return reply({ kind: 'denied', reason: 'The host gave no access for this workspace' });
|
|
107
|
+
const access = { ...granted };
|
|
108
|
+
const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
109
|
+
const kind = PRESENTED[extension] ?? { type: 'code', mediaType: 'text/plain', language: LANGUAGE_OF[extension] };
|
|
110
|
+
const target = { resource: { providerId, path }, view: { kind: 'published' } };
|
|
111
|
+
const first = await files.read({ target, revision: { kind: 'latest' } }, access);
|
|
112
|
+
if (first.kind !== 'available')
|
|
113
|
+
return reply(first);
|
|
114
|
+
const text = decode(first.snapshot.bytes);
|
|
115
|
+
if (text === undefined)
|
|
116
|
+
return reply({ kind: 'denied', reason: 'The file is not valid UTF-8 text' });
|
|
117
|
+
if (first.snapshot.bytes.byteLength > maxBytes)
|
|
118
|
+
return reply({ kind: 'denied', reason: `The file is larger than ${maxBytes} bytes` });
|
|
119
|
+
if (kind.type === 'svg' && !/<svg[\s>]/i.test(text))
|
|
120
|
+
return reply({ kind: 'denied', reason: 'An SVG file must contain an <svg> element' });
|
|
121
|
+
const kept = await files.keep(path, access);
|
|
122
|
+
if (kept.kind !== 'available')
|
|
123
|
+
return reply(kept);
|
|
124
|
+
const artifact = createArtifactDescriptor({ title: path.slice(path.lastIndexOf('/') + 1), type: kind.type, mediaType: kind.mediaType, ...(kind.language ? { language: kind.language } : {}),
|
|
125
|
+
target, revision: kept.snapshot.ref.revision });
|
|
126
|
+
return reply({ kind: 'presented', artifact });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Conversation, ToolExecutionApi, ToolRegistration } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { Context } from '@earendil-works/chord';
|
|
3
|
+
export declare const ASK_USER_TOOL = "ask_user";
|
|
4
|
+
export declare const ASK_USER_MAX_ANSWER = 2000;
|
|
5
|
+
export type AskUserAnswer = {
|
|
6
|
+
kind: 'answered';
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'denied' | 'conflict' | 'unknown-question';
|
|
9
|
+
reason?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Bind an answer to the exact assistant entry that asked it. */
|
|
12
|
+
export declare const askUserQuestionId: (assistantEntryId: number, callId: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* A native Pi tool that asks the person a question and waits for the answer without holding a model request.
|
|
15
|
+
* The question is bound to its native assistant entry and tool call, so a pending question survives a restart
|
|
16
|
+
* (the replay-safe call re-attaches to it) and is cancelled with the call when the conversation is stopped.
|
|
17
|
+
* The result is the text `{"kind":"answered","answer":"..."}`. Answer it with `answerUserQuestion`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAskUserTool(options?: {
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
}): ToolRegistration;
|
|
22
|
+
/**
|
|
23
|
+
* Ask the person from inside a running native tool and wait for the answer. The question is a conversation document bound
|
|
24
|
+
* to the asking assistant entry and tool call (`askUserQuestionId`), so it survives a restart (a replay-safe call
|
|
25
|
+
* re-attaches to it) and is cancelled with the call. Answer it with `answerUserQuestion(conversation, questionId, answer)`,
|
|
26
|
+
* which accepts one of `options` (or any text when `allowFreeText`). Resolves with the answer; throws when the question is
|
|
27
|
+
* cancelled. `createAskUserTool` and `requireApproval` (`@hachej/boring-agent/approval`) both ask through this.
|
|
28
|
+
*/
|
|
29
|
+
export declare function askPerson(api: ToolExecutionApi, context: Context, question: {
|
|
30
|
+
readonly prompt: string;
|
|
31
|
+
readonly options?: readonly string[];
|
|
32
|
+
readonly allowFreeText?: boolean;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve an `ask_user` question using `askUserQuestionId(assistantEntryId, callId)` after authenticating the person.
|
|
36
|
+
* Unscoped model call IDs are accepted only when exactly one native task used them in this conversation.
|
|
37
|
+
* Exactly one answer is accepted; repeating it is idempotent.
|
|
38
|
+
*/
|
|
39
|
+
export declare function answerUserQuestion(conversation: Conversation, callId: string, answer: string, context?: Context): Promise<AskUserAnswer>;
|
|
40
|
+
//# sourceMappingURL=ask-user.d.ts.map
|
package/dist/ask-user.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { defineDocFamily, defineTool } from '@earendil-works/pi-durable';
|
|
2
|
+
import { Type } from '@earendil-works/pi-ai';
|
|
3
|
+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
4
|
+
import { AmbiguousCall, UnknownCall, callBinding, callBindingId, ownBindingKey, waitForDocument } from './durable-wait.js';
|
|
5
|
+
export const ASK_USER_TOOL = 'ask_user';
|
|
6
|
+
export const ASK_USER_MAX_ANSWER = 2000;
|
|
7
|
+
/** One member per assistant entry and tool call, in the asking conversation. */
|
|
8
|
+
const documents = defineDocFamily({
|
|
9
|
+
kind: 'boring.ask-user.question', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', family: true,
|
|
10
|
+
initial: () => ({ question: null }),
|
|
11
|
+
});
|
|
12
|
+
/** Bind an answer to the exact assistant entry that asked it. */
|
|
13
|
+
export const askUserQuestionId = callBindingId;
|
|
14
|
+
function refusal(options, allowFreeText) {
|
|
15
|
+
if (options !== undefined && (options.length < 2 || options.length > 6 || options.some(option => typeof option !== 'string' || !option.trim() || option.length > 200)
|
|
16
|
+
|| new Set(options).size !== options.length))
|
|
17
|
+
return 'options must be 2 to 6 distinct short strings';
|
|
18
|
+
if (options === undefined && allowFreeText !== true)
|
|
19
|
+
return 'give options, or set allowFreeText to true';
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A native Pi tool that asks the person a question and waits for the answer without holding a model request.
|
|
24
|
+
* The question is bound to its native assistant entry and tool call, so a pending question survives a restart
|
|
25
|
+
* (the replay-safe call re-attaches to it) and is cancelled with the call when the conversation is stopped.
|
|
26
|
+
* The result is the text `{"kind":"answered","answer":"..."}`. Answer it with `answerUserQuestion`.
|
|
27
|
+
*/
|
|
28
|
+
export function createAskUserTool(options = {}) {
|
|
29
|
+
return defineTool({
|
|
30
|
+
name: ASK_USER_TOOL,
|
|
31
|
+
description: options.description ?? 'Ask the person a question and wait for their answer. Give 2 to 6 short options, or allow free text, or both.',
|
|
32
|
+
parameters: Type.Object({
|
|
33
|
+
question: Type.String({ minLength: 1, maxLength: 1000 }),
|
|
34
|
+
options: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 2, maxItems: 6 })),
|
|
35
|
+
allowFreeText: Type.Optional(Type.Boolean()),
|
|
36
|
+
}, { additionalProperties: false }),
|
|
37
|
+
replay: 'safe',
|
|
38
|
+
execute: async (args, api, context) => {
|
|
39
|
+
const problem = refusal(args.options, args.allowFreeText);
|
|
40
|
+
if (problem)
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: `Invalid question: ${problem}.` }] };
|
|
42
|
+
const answer = await askPerson(api, context, { prompt: args.question, ...(args.options === undefined ? {} : { options: args.options }), allowFreeText: args.allowFreeText === true });
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify({ kind: 'answered', answer }) }] };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ask the person from inside a running native tool and wait for the answer. The question is a conversation document bound
|
|
49
|
+
* to the asking assistant entry and tool call (`askUserQuestionId`), so it survives a restart (a replay-safe call
|
|
50
|
+
* re-attaches to it) and is cancelled with the call. Answer it with `answerUserQuestion(conversation, questionId, answer)`,
|
|
51
|
+
* which accepts one of `options` (or any text when `allowFreeText`). Resolves with the answer; throws when the question is
|
|
52
|
+
* cancelled. `createAskUserTool` and `requireApproval` (`@hachej/boring-agent/approval`) both ask through this.
|
|
53
|
+
*/
|
|
54
|
+
export async function askPerson(api, context, question) {
|
|
55
|
+
const key = await api.commit(async (tx) => {
|
|
56
|
+
const key = await ownBindingKey(tx, api);
|
|
57
|
+
const document = await tx.doc(documents, api.conversationId, key, null);
|
|
58
|
+
if (!document.question) {
|
|
59
|
+
const legacy = await tx.doc(documents, api.conversationId, api.callId, null);
|
|
60
|
+
document.question = legacy.question?.taskId === api.taskId ? { ...legacy.question, options: [...legacy.question.options], state: { ...legacy.question.state } }
|
|
61
|
+
: { prompt: question.prompt, options: [...(question.options ?? [])], allowFreeText: question.allowFreeText === true, taskId: api.taskId, state: { kind: 'pending' } };
|
|
62
|
+
}
|
|
63
|
+
if (document.question.taskId !== api.taskId)
|
|
64
|
+
throw new Error('Question identity belongs to another task');
|
|
65
|
+
return key;
|
|
66
|
+
}, context);
|
|
67
|
+
const final = await waitForDocument(api, documents, key, context, value => value?.question?.state.kind !== 'pending', async (tx) => {
|
|
68
|
+
const { question: record } = await tx.doc(documents, api.conversationId, key, null);
|
|
69
|
+
if (record?.state.kind === 'pending')
|
|
70
|
+
record.state = { kind: 'cancelled' };
|
|
71
|
+
}, 'Question');
|
|
72
|
+
const state = final?.question?.state;
|
|
73
|
+
if (state?.kind !== 'answered')
|
|
74
|
+
throw new Error('Question cancelled');
|
|
75
|
+
return state.answer;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve an `ask_user` question using `askUserQuestionId(assistantEntryId, callId)` after authenticating the person.
|
|
79
|
+
* Unscoped model call IDs are accepted only when exactly one native task used them in this conversation.
|
|
80
|
+
* Exactly one answer is accepted; repeating it is idempotent.
|
|
81
|
+
*/
|
|
82
|
+
export async function answerUserQuestion(conversation, callId, answer, context = BACKGROUND_CONTEXT) {
|
|
83
|
+
if (typeof callId !== 'string' || !callId || callId.length > 1024)
|
|
84
|
+
return { kind: 'unknown-question' };
|
|
85
|
+
if (typeof answer !== 'string')
|
|
86
|
+
return { kind: 'denied', reason: 'The answer must be text' };
|
|
87
|
+
const text = answer.trim();
|
|
88
|
+
try {
|
|
89
|
+
return await conversation.commit(async (tx) => {
|
|
90
|
+
const binding = await callBinding(tx, conversation, callId);
|
|
91
|
+
const task = await tx.task(binding.taskId);
|
|
92
|
+
if (!task)
|
|
93
|
+
return { kind: 'conflict', reason: 'The question task is unavailable' };
|
|
94
|
+
const document = await tx.doc(documents, conversation.id, binding.key, null);
|
|
95
|
+
if (!document.question) {
|
|
96
|
+
const legacy = await tx.doc(documents, conversation.id, binding.callId, null);
|
|
97
|
+
// Throwing rolls back the empty drafts created by an unknown question lookup.
|
|
98
|
+
if (legacy.question?.taskId !== task.id)
|
|
99
|
+
throw new UnknownCall();
|
|
100
|
+
document.question = { ...legacy.question, options: [...legacy.question.options], state: { ...legacy.question.state } };
|
|
101
|
+
}
|
|
102
|
+
const question = document.question;
|
|
103
|
+
if (question.taskId !== task.id)
|
|
104
|
+
return { kind: 'conflict', reason: 'The question belongs to another task' };
|
|
105
|
+
if (task.abortRequested) {
|
|
106
|
+
question.state = { kind: 'cancelled' };
|
|
107
|
+
return { kind: 'conflict', reason: 'The question was cancelled' };
|
|
108
|
+
}
|
|
109
|
+
if (question.state.kind === 'answered')
|
|
110
|
+
return question.state.answer === text ? { kind: 'answered' } : { kind: 'conflict', reason: 'The question was already answered' };
|
|
111
|
+
if (question.state.kind === 'cancelled')
|
|
112
|
+
return { kind: 'conflict', reason: 'The question was cancelled' };
|
|
113
|
+
if (task.state.status === 'terminal') {
|
|
114
|
+
question.state = { kind: 'cancelled' };
|
|
115
|
+
return { kind: 'conflict', reason: 'The question was cancelled' };
|
|
116
|
+
}
|
|
117
|
+
if (!text)
|
|
118
|
+
return { kind: 'denied', reason: 'The answer is empty' };
|
|
119
|
+
if (text.length > ASK_USER_MAX_ANSWER)
|
|
120
|
+
return { kind: 'denied', reason: `The answer is longer than ${ASK_USER_MAX_ANSWER} characters` };
|
|
121
|
+
if (!question.allowFreeText && !question.options.includes(text))
|
|
122
|
+
return { kind: 'denied', reason: 'The answer is not one of the options' };
|
|
123
|
+
question.state = { kind: 'answered', answer: text };
|
|
124
|
+
return { kind: 'answered' };
|
|
125
|
+
}, context);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof AmbiguousCall)
|
|
129
|
+
return { kind: 'conflict', reason: 'Use the assistant-entry-bound question ID' };
|
|
130
|
+
if (error instanceof UnknownCall)
|
|
131
|
+
return { kind: 'unknown-question' };
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Harness } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { AttachmentInput, BorrowedAttachment } from './contracts.js';
|
|
3
|
+
/** Passive borrowing acquires no observer, storage or execution lifetime. */
|
|
4
|
+
export declare function attachHarness<NativeHarness extends Harness>(input: AttachmentInput<NativeHarness>): BorrowedAttachment<NativeHarness>;
|
|
5
|
+
//# sourceMappingURL=attachment.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Conversation, JsonObject, ToolRegistration } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { TSchema } from '@earendil-works/pi-ai';
|
|
3
|
+
import type { Context } from '@earendil-works/chord';
|
|
4
|
+
import type { AskUserAnswer } from './ask-user.js';
|
|
5
|
+
/** Upper bound on an answer's UTF-8 JSON, by default. */
|
|
6
|
+
export declare const BROWSER_TASK_MAX_ANSWER = 16384;
|
|
7
|
+
/** The answer ID of a browser task call: the same `[assistantEntryId, callId]` binding as `askUserQuestionId`. */
|
|
8
|
+
export declare const browserTaskId: (assistantEntryId: number, callId: string) => string;
|
|
9
|
+
export interface BrowserTaskOptions<P extends TSchema> {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly description: string;
|
|
12
|
+
/** The task the page receives, as the tool's parameters (the page reads them from the call). */
|
|
13
|
+
readonly parameters: P;
|
|
14
|
+
/** Refuses an answer the page sent: return the reason, or nothing to accept it. Runs before anything is stored. */
|
|
15
|
+
readonly checkAnswer: (answer: JsonObject) => string | undefined;
|
|
16
|
+
/** Default `BROWSER_TASK_MAX_ANSWER`. */
|
|
17
|
+
readonly maxAnswerBytes?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A native Pi tool that waits for the person's page to answer, without holding a model request. Pending calls survive a restart
|
|
21
|
+
* (the replay-safe call re-attaches to its record) and are cancelled with the call when the conversation is stopped. The result
|
|
22
|
+
* text is the page's answer, as JSON. Answer it with `answerBrowserTask`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createBrowserTaskTool<P extends TSchema>(options: BrowserTaskOptions<P>): ToolRegistration;
|
|
25
|
+
/**
|
|
26
|
+
* Settles a pending browser task with the page's answer (a JSON object as text) after the host authenticated the person. `id` is
|
|
27
|
+
* `browserTaskId(assistantEntryId, callId)` (an unscoped call ID only when exactly one native task used it). The answer goes through
|
|
28
|
+
* the tool's own `checkAnswer` (pass the same options as the tool). Exactly one answer is accepted; repeating it is idempotent.
|
|
29
|
+
* Returns the `ask_user` outcome kinds, so one chat transport `answer` serves both.
|
|
30
|
+
*/
|
|
31
|
+
export declare function answerBrowserTask(conversation: Conversation, tool: Pick<BrowserTaskOptions<TSchema>, 'name' | 'checkAnswer' | 'maxAnswerBytes'>, id: string, answer: string, context?: Context): Promise<AskUserAnswer>;
|
|
32
|
+
//# sourceMappingURL=browser-task.d.ts.map
|