purra-interaction 0.5.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 +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +81 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +371 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lybrands
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# purra-interaction · TypeScript
|
|
2
|
+
|
|
3
|
+
English | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Persist structured questions, wait for user input, and continue execution.
|
|
6
|
+
Requires Node.js 22.13+.
|
|
7
|
+
|
|
8
|
+
| API | Use |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `SqliteClarification` | Suspend and resume the same Run using `purra-sqlite` checkpoints |
|
|
11
|
+
| `ClarificationStore` + `ClarificationWorkflow` | Collect input before execution, then create a Run through an application callback |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Follow [source installation](../../README.md#source-installation). Build and
|
|
16
|
+
install `purra-sqlite` before `integrations/interaction/typescript` when using
|
|
17
|
+
`SqliteClarification`.
|
|
18
|
+
|
|
19
|
+
## Configure a Run
|
|
20
|
+
|
|
21
|
+
Given a model gateway `model`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { Agent } from "purra";
|
|
25
|
+
import { SqliteAgentAdapters } from "purra-sqlite";
|
|
26
|
+
import { SqliteClarification } from "purra-interaction";
|
|
27
|
+
|
|
28
|
+
const storage = new SqliteAgentAdapters("agent.db", { scope: "user-1/project-1" });
|
|
29
|
+
const interaction = new SqliteClarification(storage);
|
|
30
|
+
const agent = new Agent({
|
|
31
|
+
model,
|
|
32
|
+
preset: { id: "assistant", revision: "1" },
|
|
33
|
+
tools: [interaction.tool],
|
|
34
|
+
checkpointHandler: interaction.checkpointHandler,
|
|
35
|
+
runRepository: storage.runs,
|
|
36
|
+
outputPublisher: storage.publisher,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Submit through `agent.submit(request, { budgets: { maxRunGenerationTokens: 8192 } })`.
|
|
41
|
+
When the Agent asks a question, the handle's `result` rejects with
|
|
42
|
+
`UserInputRequired` from `purra`. Use `error.requestId` with
|
|
43
|
+
`await interaction.get(error.requestId)` to obtain public question data.
|
|
44
|
+
|
|
45
|
+
The Run remains waiting without keeping a worker or model call alive. After the
|
|
46
|
+
user answers, provide the question revision, a stable command key, and answers
|
|
47
|
+
keyed by question ID:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
async function answerAndResume(
|
|
51
|
+
requestId: string,
|
|
52
|
+
revision: number,
|
|
53
|
+
answerKey: string,
|
|
54
|
+
answers: Record<string, string>,
|
|
55
|
+
) {
|
|
56
|
+
const ready = await interaction.answer(requestId, {
|
|
57
|
+
revision, key: answerKey, answers,
|
|
58
|
+
});
|
|
59
|
+
return interaction.resume(agent, ready.id);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The returned handle continues the same Run with its spent budget and deadline.
|
|
64
|
+
It may request input again. Answers provide task data and do not grant tool permissions.
|
|
65
|
+
|
|
66
|
+
## Reload and cancel
|
|
67
|
+
|
|
68
|
+
Recreate the same storage, Agent preset, gateway, and interaction objects after
|
|
69
|
+
restart. `listPending()` includes waiting and answered requests; resume those
|
|
70
|
+
in `ready` state. Use `cancel(requestId)` while waiting, or the Run handle after
|
|
71
|
+
execution resumes.
|
|
72
|
+
|
|
73
|
+
For Agent trees, bind the same Run-tree repository and answer all pending questions
|
|
74
|
+
under a Root before resuming. Auto, Reactive, and Planned execution are supported.
|
|
75
|
+
Pauses occur at completed tool-round boundaries. Reconcile uncertain external
|
|
76
|
+
writes and wait for active execution to settle before closing storage.
|
|
77
|
+
|
|
78
|
+
## Pre-execution input
|
|
79
|
+
|
|
80
|
+
`ClarificationStore.ask(...)` saves questions and an application checkpoint.
|
|
81
|
+
Expose `ClarificationStore.publicView(saved)` to the UI, then call `store.answer(...)`.
|
|
82
|
+
`ClarificationWorkflow.resume(id, { revision, submit })` invokes
|
|
83
|
+
`submit(snapshot, continuationKey)` to create a new Run and return its ID.
|
|
84
|
+
|
|
85
|
+
Persist the continuation key on that Run. If submission is interrupted, reconcile
|
|
86
|
+
against the existing Run before retrying. The application restores credentials,
|
|
87
|
+
permissions, and remaining task budgets. Close the store on shutdown.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# purra-interaction · TypeScript
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
持久化结构化问题,等待用户输入后继续执行。要求 Node.js 22.13+。
|
|
6
|
+
|
|
7
|
+
| API | 用途 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `SqliteClarification` | 通过 `purra-sqlite` 检查点暂停并恢复同一 Run |
|
|
10
|
+
| `ClarificationStore` + `ClarificationWorkflow` | 在执行前收集输入,再通过应用回调创建 Run |
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
按照[源码安装说明](../../README.zh-CN.md#从源码安装)操作。
|
|
15
|
+
使用 `SqliteClarification` 时,先构建并安装 `purra-sqlite`,再处理
|
|
16
|
+
`integrations/interaction/typescript`。
|
|
17
|
+
|
|
18
|
+
## 配置 Run
|
|
19
|
+
|
|
20
|
+
已有模型网关 `model` 时:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Agent } from "purra";
|
|
24
|
+
import { SqliteAgentAdapters } from "purra-sqlite";
|
|
25
|
+
import { SqliteClarification } from "purra-interaction";
|
|
26
|
+
|
|
27
|
+
const storage = new SqliteAgentAdapters("agent.db", { scope: "user-1/project-1" });
|
|
28
|
+
const interaction = new SqliteClarification(storage);
|
|
29
|
+
const agent = new Agent({
|
|
30
|
+
model,
|
|
31
|
+
preset: { id: "assistant", revision: "1" },
|
|
32
|
+
tools: [interaction.tool],
|
|
33
|
+
checkpointHandler: interaction.checkpointHandler,
|
|
34
|
+
runRepository: storage.runs,
|
|
35
|
+
outputPublisher: storage.publisher,
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
通过 `agent.submit(request, { budgets: { maxRunGenerationTokens: 8192 } })` 提交。
|
|
40
|
+
Agent 提问后,句柄的 `result` 会以 `purra` 导出的 `UserInputRequired` 拒绝。
|
|
41
|
+
使用异常中的 `error.requestId` 调用 `await interaction.get(error.requestId)`,获取可展示的问题数据。
|
|
42
|
+
|
|
43
|
+
Run 保持等待状态,不占用持续运行的执行任务或模型调用。收到用户回答后,传入问题修订号、
|
|
44
|
+
稳定的命令键,以及按问题 ID 组织的回答:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
async function answerAndResume(
|
|
48
|
+
requestId: string,
|
|
49
|
+
revision: number,
|
|
50
|
+
answerKey: string,
|
|
51
|
+
answers: Record<string, string>,
|
|
52
|
+
) {
|
|
53
|
+
const ready = await interaction.answer(requestId, {
|
|
54
|
+
revision, key: answerKey, answers,
|
|
55
|
+
});
|
|
56
|
+
return interaction.resume(agent, ready.id);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
返回的句柄继续执行原 Run,保留已消耗预算和截止时间,也可能再次请求输入。
|
|
61
|
+
回答只提供任务数据,不授予工具权限。
|
|
62
|
+
|
|
63
|
+
## 重新加载与取消
|
|
64
|
+
|
|
65
|
+
进程重启后,还原相同的存储、Agent preset、网关和交互对象。
|
|
66
|
+
`listPending()` 包含等待中和已回答的请求,可恢复其中处于 `ready` 状态的记录。
|
|
67
|
+
等待期间使用 `cancel(requestId)`;恢复执行后使用 Run 句柄取消。
|
|
68
|
+
|
|
69
|
+
使用 Agent 树时,绑定同一 Run 树仓储,回答 Root 下的全部待答问题后再恢复。
|
|
70
|
+
支持 Auto、Reactive 和 Planned。暂停发生在完整工具轮次结束后。
|
|
71
|
+
结果不确定的外部写入需要先确认;等待活动执行结束后再关闭存储。
|
|
72
|
+
|
|
73
|
+
## 执行前收集输入
|
|
74
|
+
|
|
75
|
+
`ClarificationStore.ask(...)` 保存问题和应用检查点。
|
|
76
|
+
向界面提供 `ClarificationStore.publicView(saved)`,再通过 `store.answer(...)` 保存回答。
|
|
77
|
+
`ClarificationWorkflow.resume(id, { revision, submit })` 调用
|
|
78
|
+
`submit(snapshot, continuationKey)` 创建新 Run 并返回其 ID。
|
|
79
|
+
|
|
80
|
+
将续接键保存在对应 Run 上。提交中断时,先根据已有 Run 确认结果再重试。
|
|
81
|
+
应用负责还原凭据、权限和任务剩余预算,并在关闭时关闭 store。
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type Agent, type AgentExecutionCheckpoint, type RunRequest, type RunLeaseClaim, type ToolDefinition, type RunHandle } from "purra";
|
|
2
|
+
import type { SqliteAgentAdapters } from "purra-sqlite";
|
|
3
|
+
import type { JsonValue, TaskAdmissionDecision } from "purra";
|
|
4
|
+
export interface InputQuestion {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly prompt: string;
|
|
7
|
+
readonly choices?: readonly string[];
|
|
8
|
+
readonly allowFreeform?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ClarificationInput {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly questions: readonly InputQuestion[];
|
|
13
|
+
readonly checkpoint: JsonValue;
|
|
14
|
+
readonly sourceRunId?: string | null;
|
|
15
|
+
readonly expiresAtMs?: number | null;
|
|
16
|
+
}
|
|
17
|
+
export interface ClarificationSnapshot {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly questions: readonly Required<InputQuestion>[];
|
|
20
|
+
readonly checkpoint: JsonValue;
|
|
21
|
+
readonly sourceRunId: string | null;
|
|
22
|
+
readonly expiresAtMs: number | null;
|
|
23
|
+
readonly state: "waiting" | "ready" | "resuming" | "resumed" | "canceled";
|
|
24
|
+
readonly revision: number;
|
|
25
|
+
readonly answers: Readonly<Record<string, string>> | null;
|
|
26
|
+
readonly runId: string | null;
|
|
27
|
+
readonly resumeToken: string | null;
|
|
28
|
+
}
|
|
29
|
+
/** SQLite clarification storage. It does not replace the canonical Core Run journal. */
|
|
30
|
+
export declare class ClarificationStore {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(path: string, options: {
|
|
33
|
+
scope: string;
|
|
34
|
+
});
|
|
35
|
+
ask(input: ClarificationInput): ClarificationSnapshot;
|
|
36
|
+
get(id: string): ClarificationSnapshot;
|
|
37
|
+
answer(id: string, options: {
|
|
38
|
+
revision: number;
|
|
39
|
+
key: string;
|
|
40
|
+
answers: Readonly<Record<string, string>>;
|
|
41
|
+
}): ClarificationSnapshot;
|
|
42
|
+
claim(id: string, options: {
|
|
43
|
+
revision: number;
|
|
44
|
+
}): {
|
|
45
|
+
token: string;
|
|
46
|
+
snapshot: ClarificationSnapshot;
|
|
47
|
+
};
|
|
48
|
+
reconcile(id: string, options: {
|
|
49
|
+
token: string;
|
|
50
|
+
runId?: string;
|
|
51
|
+
notSubmitted?: boolean;
|
|
52
|
+
}): ClarificationSnapshot;
|
|
53
|
+
cancel(id: string, options: {
|
|
54
|
+
revision: number;
|
|
55
|
+
}): ClarificationSnapshot;
|
|
56
|
+
static publicView(saved: ClarificationSnapshot): Omit<ClarificationSnapshot, "checkpoint" | "resumeToken">;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
export declare class ClarificationWorkflow {
|
|
60
|
+
readonly store: ClarificationStore;
|
|
61
|
+
constructor(store: ClarificationStore);
|
|
62
|
+
admission(input: ClarificationInput): TaskAdmissionDecision;
|
|
63
|
+
resume(id: string, options: {
|
|
64
|
+
revision: number;
|
|
65
|
+
submit: (snapshot: ClarificationSnapshot, continuationKey: string) => Promise<string>;
|
|
66
|
+
}): Promise<ClarificationSnapshot>;
|
|
67
|
+
}
|
|
68
|
+
/** Compose with SqliteAgentAdapters and Agent.checkpointHandler. */
|
|
69
|
+
export declare class SqliteClarification {
|
|
70
|
+
#private;
|
|
71
|
+
readonly storage: SqliteAgentAdapters;
|
|
72
|
+
readonly tool: ToolDefinition;
|
|
73
|
+
constructor(storage: SqliteAgentAdapters);
|
|
74
|
+
readonly checkpointHandler: (checkpoint: AgentExecutionCheckpoint, request: RunRequest, claim?: RunLeaseClaim) => Promise<AgentExecutionCheckpoint>;
|
|
75
|
+
checkpoint(checkpoint: AgentExecutionCheckpoint, request: RunRequest, claim?: RunLeaseClaim): Promise<AgentExecutionCheckpoint>;
|
|
76
|
+
get(id: string): Promise<{
|
|
77
|
+
id: string;
|
|
78
|
+
runId: string;
|
|
79
|
+
rootRunId: string;
|
|
80
|
+
questions: JsonValue;
|
|
81
|
+
revision: number;
|
|
82
|
+
state: string;
|
|
83
|
+
answers: Readonly<Record<string, string>> | null;
|
|
84
|
+
}>;
|
|
85
|
+
listPending(): Promise<{
|
|
86
|
+
id: string;
|
|
87
|
+
runId: string;
|
|
88
|
+
rootRunId: string;
|
|
89
|
+
questions: JsonValue;
|
|
90
|
+
revision: number;
|
|
91
|
+
state: string;
|
|
92
|
+
answers: Readonly<Record<string, string>> | null;
|
|
93
|
+
}[]>;
|
|
94
|
+
listWaiting(): Promise<{
|
|
95
|
+
id: string;
|
|
96
|
+
runId: string;
|
|
97
|
+
rootRunId: string;
|
|
98
|
+
questions: JsonValue;
|
|
99
|
+
state: "ready" | "waiting";
|
|
100
|
+
revision: number;
|
|
101
|
+
}[]>;
|
|
102
|
+
answer(id: string, options: {
|
|
103
|
+
revision: number;
|
|
104
|
+
key: string;
|
|
105
|
+
answers: Readonly<Record<string, string>>;
|
|
106
|
+
}): Promise<{
|
|
107
|
+
id: string;
|
|
108
|
+
runId: string;
|
|
109
|
+
rootRunId: string;
|
|
110
|
+
questions: JsonValue;
|
|
111
|
+
revision: number;
|
|
112
|
+
state: string;
|
|
113
|
+
answers: Readonly<Record<string, string>> | null;
|
|
114
|
+
}>;
|
|
115
|
+
resume(agent: Agent, id: string): Promise<RunHandle>;
|
|
116
|
+
cancel(id: string): Promise<boolean>;
|
|
117
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { UserInputRequired } from "purra";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
function text(value, limit = 512) {
|
|
6
|
+
if (typeof value !== "string" || !value.trim() || [...value].length > limit)
|
|
7
|
+
throw new TypeError("Expected bounded non-empty text");
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function encode(value) {
|
|
11
|
+
const active = new Set();
|
|
12
|
+
function copy(v) {
|
|
13
|
+
if (v === null || typeof v === "string" || typeof v === "boolean")
|
|
14
|
+
return v;
|
|
15
|
+
if (typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= Number.MAX_SAFE_INTEGER)
|
|
16
|
+
return v;
|
|
17
|
+
if (!v || typeof v !== "object" || active.has(v))
|
|
18
|
+
throw new TypeError("Checkpoint must be finite JSON data");
|
|
19
|
+
active.add(v);
|
|
20
|
+
try {
|
|
21
|
+
if (Array.isArray(v))
|
|
22
|
+
return v.map(copy);
|
|
23
|
+
if (Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null)
|
|
24
|
+
throw new TypeError("Checkpoint must be plain JSON data");
|
|
25
|
+
return Object.fromEntries(Object.keys(v).sort().map(k => [k, copy(v[k])]));
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
active.delete(v);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const result = JSON.stringify(copy(value));
|
|
32
|
+
if (Buffer.byteLength(result) > 131072)
|
|
33
|
+
throw new TypeError("Clarification exceeds 128 KiB");
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function questions(values) {
|
|
37
|
+
if (!Array.isArray(values) || values.length < 1 || values.length > 3)
|
|
38
|
+
throw new TypeError("Clarification requires 1 to 3 questions");
|
|
39
|
+
const result = values.map(q => {
|
|
40
|
+
if (!q || typeof q !== "object" || Object.keys(q).some(k => !["id", "prompt", "choices", "allowFreeform"].includes(k)))
|
|
41
|
+
throw new TypeError("Invalid question");
|
|
42
|
+
const choices = q.choices ?? [];
|
|
43
|
+
if (!Array.isArray(choices) || choices.length > 8 || new Set(choices).size !== choices.length)
|
|
44
|
+
throw new TypeError("Invalid choices");
|
|
45
|
+
const allowFreeform = q.allowFreeform ?? true;
|
|
46
|
+
if (typeof allowFreeform !== "boolean" || (!allowFreeform && !choices.length))
|
|
47
|
+
throw new TypeError("Invalid answer mode");
|
|
48
|
+
return { id: text(q.id, 128), prompt: text(q.prompt, 8000), choices: choices.map(c => text(c, 128)), allowFreeform };
|
|
49
|
+
});
|
|
50
|
+
if (new Set(result.map(q => q.id)).size !== result.length)
|
|
51
|
+
throw new TypeError("Question ids must be unique");
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/** SQLite clarification storage. It does not replace the canonical Core Run journal. */
|
|
55
|
+
export class ClarificationStore {
|
|
56
|
+
#db;
|
|
57
|
+
#scope;
|
|
58
|
+
constructor(path, options) {
|
|
59
|
+
this.#scope = text(options.scope);
|
|
60
|
+
this.#db = new DatabaseSync(path);
|
|
61
|
+
this.#db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
|
|
62
|
+
CREATE TABLE IF NOT EXISTS purra_clarifications (
|
|
63
|
+
scope TEXT NOT NULL, id TEXT NOT NULL, request_key TEXT NOT NULL,
|
|
64
|
+
request_json TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
65
|
+
answer_key TEXT, answers_json TEXT, resume_token TEXT, run_id TEXT,
|
|
66
|
+
PRIMARY KEY(scope, id), UNIQUE(scope, request_key));`);
|
|
67
|
+
}
|
|
68
|
+
#transaction(action) {
|
|
69
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
70
|
+
try {
|
|
71
|
+
const result = action();
|
|
72
|
+
this.#db.exec("COMMIT");
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this.#db.exec("ROLLBACK");
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
ask(input) {
|
|
81
|
+
text(input.key);
|
|
82
|
+
if (input.sourceRunId != null)
|
|
83
|
+
text(input.sourceRunId);
|
|
84
|
+
if (input.expiresAtMs != null && (!Number.isSafeInteger(input.expiresAtMs) || input.expiresAtMs <= 0))
|
|
85
|
+
throw new TypeError("Expiry must be a positive integer");
|
|
86
|
+
const body = encode({ questions: questions(input.questions), checkpoint: input.checkpoint,
|
|
87
|
+
sourceRunId: input.sourceRunId ?? null, expiresAtMs: input.expiresAtMs ?? null });
|
|
88
|
+
const id = this.#transaction(() => {
|
|
89
|
+
const existing = this.#db.prepare("SELECT id,request_json FROM purra_clarifications WHERE scope=? AND request_key=?").get(this.#scope, input.key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (encode(JSON.parse(existing.request_json)) !== body)
|
|
92
|
+
throw new Error("clarification_idempotency_conflict");
|
|
93
|
+
return existing.id;
|
|
94
|
+
}
|
|
95
|
+
const identifier = randomUUID().replaceAll("-", "");
|
|
96
|
+
this.#db.prepare("INSERT INTO purra_clarifications(scope,id,request_key,request_json,state,revision) VALUES(?,?,?,?,'waiting',1)").run(this.#scope, identifier, input.key, body);
|
|
97
|
+
return identifier;
|
|
98
|
+
});
|
|
99
|
+
return this.get(id);
|
|
100
|
+
}
|
|
101
|
+
get(id) {
|
|
102
|
+
const row = this.#db.prepare("SELECT request_json,state,revision,answers_json,run_id,resume_token FROM purra_clarifications WHERE scope=? AND id=?").get(this.#scope, text(id));
|
|
103
|
+
if (!row)
|
|
104
|
+
throw new Error("clarification_not_found");
|
|
105
|
+
return { id, ...JSON.parse(row.request_json), state: row.state, revision: row.revision,
|
|
106
|
+
answers: row.answers_json === null ? null : JSON.parse(row.answers_json), runId: row.run_id, resumeToken: row.resume_token };
|
|
107
|
+
}
|
|
108
|
+
answer(id, options) {
|
|
109
|
+
text(options.key);
|
|
110
|
+
const encoded = encode(options.answers);
|
|
111
|
+
this.#transaction(() => {
|
|
112
|
+
const saved = this.get(id);
|
|
113
|
+
const previous = this.#db.prepare("SELECT answer_key,answers_json FROM purra_clarifications WHERE scope=? AND id=?").get(this.#scope, id);
|
|
114
|
+
if (previous.answer_key === options.key) {
|
|
115
|
+
if (encode(JSON.parse(previous.answers_json)) !== encoded)
|
|
116
|
+
throw new Error("clarification_idempotency_conflict");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.#require(saved, "waiting", options.revision);
|
|
120
|
+
if (!options.answers || typeof options.answers !== "object" || Array.isArray(options.answers)
|
|
121
|
+
|| Object.keys(options.answers).length !== saved.questions.length || saved.questions.some(q => !Object.hasOwn(options.answers, q.id)))
|
|
122
|
+
throw new TypeError("Answers must cover exactly the requested questions");
|
|
123
|
+
for (const q of saved.questions) {
|
|
124
|
+
const answer = text(options.answers[q.id], 8000);
|
|
125
|
+
if (!q.allowFreeform && !q.choices.includes(answer))
|
|
126
|
+
throw new TypeError("Answer is not one of the offered choices");
|
|
127
|
+
}
|
|
128
|
+
this.#db.prepare("UPDATE purra_clarifications SET state='ready',revision=revision+1,answer_key=?,answers_json=? WHERE scope=? AND id=?").run(options.key, encoded, this.#scope, id);
|
|
129
|
+
});
|
|
130
|
+
return this.get(id);
|
|
131
|
+
}
|
|
132
|
+
claim(id, options) {
|
|
133
|
+
const token = this.#transaction(() => {
|
|
134
|
+
this.#require(this.get(id), "ready", options.revision);
|
|
135
|
+
const value = randomUUID().replaceAll("-", "");
|
|
136
|
+
this.#db.prepare("UPDATE purra_clarifications SET state='resuming',revision=revision+1,resume_token=? WHERE scope=? AND id=?").run(value, this.#scope, id);
|
|
137
|
+
return value;
|
|
138
|
+
});
|
|
139
|
+
return { token, snapshot: this.get(id) };
|
|
140
|
+
}
|
|
141
|
+
reconcile(id, options) {
|
|
142
|
+
if (options.notSubmitted !== undefined && typeof options.notSubmitted !== "boolean")
|
|
143
|
+
throw new TypeError("notSubmitted must be boolean");
|
|
144
|
+
if ((options.runId === undefined) === (options.notSubmitted !== true))
|
|
145
|
+
throw new TypeError("Provide an existing Run id or notSubmitted=true");
|
|
146
|
+
if (options.runId !== undefined)
|
|
147
|
+
text(options.runId);
|
|
148
|
+
this.#transaction(() => {
|
|
149
|
+
const row = this.#db.prepare("SELECT state,resume_token,run_id FROM purra_clarifications WHERE scope=? AND id=?").get(this.#scope, id);
|
|
150
|
+
if (!row || row.resume_token !== options.token)
|
|
151
|
+
throw new Error("clarification_claim_conflict");
|
|
152
|
+
if (row.state === "resumed" && row.run_id === options.runId)
|
|
153
|
+
return;
|
|
154
|
+
if (row.state !== "resuming")
|
|
155
|
+
throw new Error("clarification_state_conflict");
|
|
156
|
+
this.#db.prepare("UPDATE purra_clarifications SET state=?,revision=revision+1,run_id=? WHERE scope=? AND id=?").run(options.notSubmitted ? "ready" : "resumed", options.runId ?? null, this.#scope, id);
|
|
157
|
+
});
|
|
158
|
+
return this.get(id);
|
|
159
|
+
}
|
|
160
|
+
cancel(id, options) {
|
|
161
|
+
this.#transaction(() => {
|
|
162
|
+
const saved = this.get(id);
|
|
163
|
+
if (saved.revision !== options.revision || !["waiting", "ready"].includes(saved.state))
|
|
164
|
+
throw new Error("clarification_state_conflict");
|
|
165
|
+
this.#db.prepare("UPDATE purra_clarifications SET state='canceled',revision=revision+1 WHERE scope=? AND id=?").run(this.#scope, id);
|
|
166
|
+
});
|
|
167
|
+
return this.get(id);
|
|
168
|
+
}
|
|
169
|
+
static publicView(saved) {
|
|
170
|
+
const { checkpoint: _, resumeToken: __, ...view } = saved;
|
|
171
|
+
return view;
|
|
172
|
+
}
|
|
173
|
+
#require(saved, state, revision) {
|
|
174
|
+
if (!Number.isSafeInteger(revision) || saved.state !== state || saved.revision !== revision)
|
|
175
|
+
throw new Error("clarification_state_conflict");
|
|
176
|
+
if (saved.expiresAtMs !== null && saved.expiresAtMs <= Date.now())
|
|
177
|
+
throw new Error("clarification_expired");
|
|
178
|
+
}
|
|
179
|
+
close() { this.#db.close(); }
|
|
180
|
+
}
|
|
181
|
+
export class ClarificationWorkflow {
|
|
182
|
+
store;
|
|
183
|
+
constructor(store) {
|
|
184
|
+
this.store = store;
|
|
185
|
+
}
|
|
186
|
+
admission(input) {
|
|
187
|
+
const saved = this.store.ask(input);
|
|
188
|
+
return { mode: "clarify", reasonCode: "user_input_required", message: saved.questions.map(q => q.prompt).join("\n"), metadata: { inputRequestId: saved.id } };
|
|
189
|
+
}
|
|
190
|
+
async resume(id, options) {
|
|
191
|
+
const { token, snapshot } = this.store.claim(id, options);
|
|
192
|
+
const runId = await options.submit(snapshot, "clarification:" + id);
|
|
193
|
+
return this.store.reconcile(id, { token, runId });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Compose with SqliteAgentAdapters and Agent.checkpointHandler. */
|
|
197
|
+
export class SqliteClarification {
|
|
198
|
+
storage;
|
|
199
|
+
tool = {
|
|
200
|
+
name: "request_user_input", description: "Ask the user for missing task information. Execution waits for the answer.",
|
|
201
|
+
inputSchema: { type: "object", properties: { questions: { type: "array", minItems: 1, maxItems: 3,
|
|
202
|
+
items: { type: "object", properties: { id: { type: "string" }, prompt: { type: "string" },
|
|
203
|
+
choices: { type: "array", items: { type: "string" }, maxItems: 8 }, allowFreeform: { type: "boolean" } },
|
|
204
|
+
required: ["id", "prompt"], additionalProperties: false } } }, required: ["questions"], additionalProperties: false },
|
|
205
|
+
policy: { mode: "read", title: "Ask the user" },
|
|
206
|
+
run: (input) => ({ content: { purraInputRequest: questions(input.questions) }, effectState: "not_started" }),
|
|
207
|
+
};
|
|
208
|
+
constructor(storage) {
|
|
209
|
+
this.storage = storage;
|
|
210
|
+
}
|
|
211
|
+
checkpointHandler = (checkpoint, request, claim = {}) => this.checkpoint(checkpoint, request, claim);
|
|
212
|
+
async checkpoint(checkpoint, request, claim = {}) {
|
|
213
|
+
const calls = new Set(checkpoint.messages.flatMap(m => m.toolCalls ?? []).filter(c => c.name === "request_user_input").map(c => c.id));
|
|
214
|
+
const waiting = await this.storage.transaction(async (all, extra) => {
|
|
215
|
+
let tree;
|
|
216
|
+
try {
|
|
217
|
+
tree = await all.runTree.getRun(checkpoint.runId);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code !== "child_run_not_found")
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
const rootRunId = tree?.rootRunId ?? checkpoint.runId;
|
|
224
|
+
const rows = extra.clarifications ??= Object.create(null);
|
|
225
|
+
for (const message of checkpoint.messages) {
|
|
226
|
+
if (message.role !== "tool" || !calls.has(message.toolCallId ?? ""))
|
|
227
|
+
continue;
|
|
228
|
+
let value = message.content;
|
|
229
|
+
if (typeof value === "string") {
|
|
230
|
+
try {
|
|
231
|
+
value = JSON.parse(value);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!value?.purraInputRequest)
|
|
238
|
+
continue;
|
|
239
|
+
const id = createHash("sha256").update(checkpoint.runId + "\0" + message.toolCallId).digest("hex");
|
|
240
|
+
if (!rows[id]) {
|
|
241
|
+
const row = { id, runId: checkpoint.runId, rootRunId, questions: questions(value.purraInputRequest), state: "waiting", revision: 1, answers: null, answerKey: null, request };
|
|
242
|
+
rows[id] = row;
|
|
243
|
+
await all.runs.appendEvent(row.runId, { sourceKey: `input:${id}:required`, kind: "input.required", channel: "lifecycle", visibility: "public", payload: this.#public(row) }, claim);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const descendants = new Set(tree ? (await all.runTree.listDescendants(checkpoint.runId)).map(run => run.runId) : []);
|
|
247
|
+
descendants.add(checkpoint.runId);
|
|
248
|
+
for (const row of Object.values(rows)) {
|
|
249
|
+
if (descendants.has(row.runId) && row.state === "waiting" && (await all.runs.get(row.runId)).status === "running")
|
|
250
|
+
return row;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
});
|
|
254
|
+
if (waiting)
|
|
255
|
+
throw new UserInputRequired(checkpoint.runId, waiting.id);
|
|
256
|
+
return checkpoint;
|
|
257
|
+
}
|
|
258
|
+
#public(row) { return { id: row.id, runId: row.runId, rootRunId: row.rootRunId, questions: row.questions, state: row.state, revision: row.revision }; }
|
|
259
|
+
async get(id) {
|
|
260
|
+
return this.storage.transaction(async (all, extra) => {
|
|
261
|
+
const row = extra.clarifications?.[id];
|
|
262
|
+
if (!row)
|
|
263
|
+
throw new Error("clarification_not_found");
|
|
264
|
+
const run = await all.runs.get(row.runId);
|
|
265
|
+
const lease = extra.leases[row.runId];
|
|
266
|
+
const state = run.status !== "running" ? run.status : row.state === "ready" && lease?.owner && lease.expires > Date.now() ? "running" : row.state;
|
|
267
|
+
return { ...this.#public(row), state, answers: row.answers };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async listPending() {
|
|
271
|
+
const ids = await this.storage.transaction(async (all, extra) => {
|
|
272
|
+
const result = [];
|
|
273
|
+
for (const row of Object.values((extra.clarifications ?? {}))) {
|
|
274
|
+
if ((await all.runs.get(row.runId)).status === "running")
|
|
275
|
+
result.push(row.id);
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
});
|
|
279
|
+
return Promise.all(ids.map(id => this.get(id)));
|
|
280
|
+
}
|
|
281
|
+
async listWaiting() {
|
|
282
|
+
return this.storage.transaction(async (all, extra) => {
|
|
283
|
+
const result = [];
|
|
284
|
+
for (const row of Object.values((extra.clarifications ?? {}))) {
|
|
285
|
+
if (row.state === "waiting" && (await all.runs.get(row.runId)).status === "running")
|
|
286
|
+
result.push(this.#public(row));
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
async answer(id, options) {
|
|
292
|
+
text(options.key);
|
|
293
|
+
encode(options.answers);
|
|
294
|
+
await this.storage.transaction(async (all, extra) => {
|
|
295
|
+
const row = extra.clarifications[id];
|
|
296
|
+
if (row.answerKey === options.key) {
|
|
297
|
+
if (encode(row.answers) !== encode(options.answers))
|
|
298
|
+
throw new Error("clarification_idempotency_conflict");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (row.state !== "waiting" || row.revision !== options.revision)
|
|
302
|
+
throw new Error("clarification_revision_conflict");
|
|
303
|
+
if (!options.answers || Array.isArray(options.answers) || Object.keys(options.answers).length !== row.questions.length)
|
|
304
|
+
throw new Error("answers must cover the questions");
|
|
305
|
+
for (const q of row.questions) {
|
|
306
|
+
const value = text(options.answers[q.id], 8000);
|
|
307
|
+
if (!q.allowFreeform && !q.choices.includes(value))
|
|
308
|
+
throw new Error("invalid answer choice");
|
|
309
|
+
}
|
|
310
|
+
const snapshot = await all.runs.get(row.runId);
|
|
311
|
+
if (snapshot.status !== "running")
|
|
312
|
+
throw new Error("clarification_run_terminal");
|
|
313
|
+
if (snapshot.deadlineAt !== null && Date.parse(snapshot.deadlineAt) <= Date.now())
|
|
314
|
+
throw new Error("clarification_expired");
|
|
315
|
+
const checkpoint = snapshot.executionCheckpoint;
|
|
316
|
+
if (!checkpoint)
|
|
317
|
+
throw new Error("clarification_checkpoint_missing");
|
|
318
|
+
await all.runs.saveExecutionCheckpoint(row.runId, { ...checkpoint, inputRevision: (checkpoint.inputRevision ?? 0) + 1, messages: [...checkpoint.messages,
|
|
319
|
+
{ role: "user", content: "Answers to requested task information:\n" + encode(options.answers), attributes: { inputRequestId: id } }] });
|
|
320
|
+
row.answers = options.answers;
|
|
321
|
+
row.answerKey = options.key;
|
|
322
|
+
row.state = "ready";
|
|
323
|
+
row.revision += 1;
|
|
324
|
+
await all.runs.appendEvent(row.runId, { sourceKey: `input:${id}:answered`, kind: "input.answered", channel: "lifecycle", visibility: "public", payload: this.#public(row) });
|
|
325
|
+
});
|
|
326
|
+
return this.get(id);
|
|
327
|
+
}
|
|
328
|
+
async resume(agent, id) {
|
|
329
|
+
const row = await this.storage.transaction(async (all, extra) => {
|
|
330
|
+
const saved = extra.clarifications[id];
|
|
331
|
+
if (saved.state !== "ready")
|
|
332
|
+
throw new Error("clarification_not_ready");
|
|
333
|
+
for (const r of Object.values(extra.clarifications)) {
|
|
334
|
+
if (r.rootRunId === saved.rootRunId && r.state === "waiting" && (await all.runs.get(r.runId)).status === "running")
|
|
335
|
+
throw new Error("clarification_answers_incomplete");
|
|
336
|
+
}
|
|
337
|
+
return saved;
|
|
338
|
+
});
|
|
339
|
+
return agent.resume(row.rootRunId, row.request);
|
|
340
|
+
}
|
|
341
|
+
async cancel(id) {
|
|
342
|
+
return this.storage.transaction(async (all, extra) => {
|
|
343
|
+
const row = extra.clarifications[id];
|
|
344
|
+
let tree;
|
|
345
|
+
try {
|
|
346
|
+
tree = await all.runTree.getRun(row.rootRunId);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error.code !== "child_run_not_found")
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
const ids = [row.rootRunId, ...(tree ? (await all.runTree.listDescendants(row.rootRunId)).map(run => run.runId) : [])];
|
|
353
|
+
if (ids.some(rid => extra.leases[rid]?.owner))
|
|
354
|
+
throw new Error("cancel the active Run through its handle");
|
|
355
|
+
const accepted = (await all.runs.cancel(row.rootRunId)).accepted;
|
|
356
|
+
if (tree) {
|
|
357
|
+
for (const rid of ids.slice(1)) {
|
|
358
|
+
try {
|
|
359
|
+
await all.runs.cancel(rid);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (error.code !== "run_not_found")
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
await all.runTree.cancelSubtree(row.rootRunId);
|
|
367
|
+
}
|
|
368
|
+
return accepted;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "purra-interaction",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Durable pre-execution user clarification for PurrA",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.13.0"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"purra": "0.5.0",
|
|
23
|
+
"purra-sqlite": "0.5.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "22.20.1",
|
|
27
|
+
"purra": "file:../../../typescript",
|
|
28
|
+
"typescript": "7.0.2",
|
|
29
|
+
"purra-sqlite": "file:../../sqlite/typescript"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"test": "node --test test/*.test.mjs",
|
|
34
|
+
"check": "npm run build && npm test",
|
|
35
|
+
"prepack": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"purra-sqlite": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|