@springbrand/agent-runtime 0.3.0-alpha.7 → 0.3.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/adapter/cloudflare/agent.ts +4 -7
- package/src/adapter/cloudflare/define-agent.ts +2 -0
- package/src/adapter/cloudflare/schema.ts +3 -0
- package/src/adapter/cloudflare/subagent/definition.ts +4 -4
- package/src/adapter/cloudflare/subagent/runner.ts +1 -1
- package/src/adapter/cloudflare/subagent/tools.ts +2 -2
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +17 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/model-guidance.ts +1 -1
- package/src/config/ports.ts +5 -15
- package/src/config/public-contracts.ts +2 -2
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +1 -1
- package/src/conversation/state.ts +1 -1
- package/src/index.ts +7 -7
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +3 -5
- package/src/models/index.ts +1 -0
- package/src/session/cloudflare/migration.ts +29 -8
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +4 -0
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +1 -1
- package/src/submission/repository.ts +8 -1
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +1 -1
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +2 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +2 -2
- package/src/tools/assistant/ask-user.ts +317 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -49
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +28 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/{approval → runtime/approval}/repository.ts +3 -9
- package/src/tools/{compiler.ts → runtime/compiler.ts} +24 -155
- package/src/tools/runtime/contracts.ts +117 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +36 -61
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/{schedule.ts → schedule/index.ts} +50 -49
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1320 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -542
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{unattended-error.ts → runtime/unattended-error.ts} +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
2
|
import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { ExecutionLevel } from "../lib/execution-level";
|
|
3
4
|
import type { RuntimeUnattendedExecutionGrant } from "./unattended";
|
|
4
5
|
|
|
5
6
|
// Submission 的统一定义见 ./index.ts。
|
|
@@ -56,6 +57,7 @@ export interface StoredSubmission {
|
|
|
56
57
|
regenerateMessageId: string | null;
|
|
57
58
|
runId: string | null;
|
|
58
59
|
origin: SubmissionOrigin;
|
|
60
|
+
scheduledMaxExecutionLevel: ExecutionLevel | null;
|
|
59
61
|
unattendedExecutionGrant: RuntimeUnattendedExecutionGrant | null;
|
|
60
62
|
unattendedFailure: string | null;
|
|
61
63
|
retryable?: boolean;
|
|
@@ -82,6 +84,7 @@ export interface NewSubmission {
|
|
|
82
84
|
regenerateMessageId?: string | null;
|
|
83
85
|
runId?: string | null;
|
|
84
86
|
origin?: SubmissionOrigin;
|
|
87
|
+
scheduledMaxExecutionLevel?: ExecutionLevel;
|
|
85
88
|
unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -105,6 +108,7 @@ type SubmissionRow = {
|
|
|
105
108
|
regenerate_message_id: string | null;
|
|
106
109
|
run_id: string | null;
|
|
107
110
|
origin: string | null;
|
|
111
|
+
scheduled_max_execution_level: string | null;
|
|
108
112
|
admission_retryable: number | null;
|
|
109
113
|
unattended_execution_grant_json: string | null;
|
|
110
114
|
unattended_failure: string | null;
|
|
@@ -136,6 +140,7 @@ function mapRow(row: SubmissionRow): StoredSubmission {
|
|
|
136
140
|
regenerateMessageId: row.regenerate_message_id,
|
|
137
141
|
runId: row.run_id,
|
|
138
142
|
origin: row.origin === "scheduled" ? "scheduled" : "user",
|
|
143
|
+
scheduledMaxExecutionLevel: row.scheduled_max_execution_level as ExecutionLevel | null,
|
|
139
144
|
unattendedExecutionGrant: row.unattended_execution_grant_json
|
|
140
145
|
? JSON.parse(row.unattended_execution_grant_json) as RuntimeUnattendedExecutionGrant
|
|
141
146
|
: null,
|
|
@@ -173,6 +178,7 @@ export class SubmissionRepository {
|
|
|
173
178
|
regenerate_message_id,
|
|
174
179
|
run_id,
|
|
175
180
|
origin,
|
|
181
|
+
scheduled_max_execution_level,
|
|
176
182
|
admission_retryable,
|
|
177
183
|
unattended_execution_grant_json,
|
|
178
184
|
unattended_failure
|
|
@@ -276,7 +282,7 @@ export class SubmissionRepository {
|
|
|
276
282
|
created_at, assembly_revision, assembly_descriptor, runtime_key,
|
|
277
283
|
assistant_message_id, queued_input_json, queued_ui_message_json,
|
|
278
284
|
user_message_id, regenerate_message_id,
|
|
279
|
-
run_id, origin, unattended_execution_grant_json
|
|
285
|
+
run_id, origin, scheduled_max_execution_level, unattended_execution_grant_json
|
|
280
286
|
) VALUES (
|
|
281
287
|
${s.submissionId},
|
|
282
288
|
${s.requestId},
|
|
@@ -294,6 +300,7 @@ export class SubmissionRepository {
|
|
|
294
300
|
${s.regenerateMessageId ?? null},
|
|
295
301
|
${s.runId ?? s.submissionId},
|
|
296
302
|
${s.origin ?? "user"},
|
|
303
|
+
${s.scheduledMaxExecutionLevel ?? null},
|
|
297
304
|
${s.unattendedExecutionGrant
|
|
298
305
|
? JSON.stringify(s.unattendedExecutionGrant)
|
|
299
306
|
: null}
|
|
@@ -5,7 +5,7 @@ import { TelemetryRecorder } from "./recorder";
|
|
|
5
5
|
import { RuntimeTelemetry } from "./runtime-telemetry";
|
|
6
6
|
import type { AgentTelemetryBinding } from "./contract";
|
|
7
7
|
import type { PiGenerationLifecycleEvent } from "../models";
|
|
8
|
-
import type { TemporaryAgentLifecycleEvent } from "../tools/temporary
|
|
8
|
+
import type { TemporaryAgentLifecycleEvent } from "../tools/subagent/temporary/coordinator";
|
|
9
9
|
|
|
10
10
|
const TELEMETRY_COLD_BUFFER_LIMIT = 1_000;
|
|
11
11
|
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
RuntimeTurnEventsPort,
|
|
8
8
|
} from "../config/ports";
|
|
9
9
|
import type { StoredSubmission } from "../submission/repository";
|
|
10
|
-
import { decodeSubagentCompletion, isZeroSubagentUsage, subagentTerminalStatus, type RuntimeSubagentRunMetadata } from "../tools/subagent
|
|
10
|
+
import { decodeSubagentCompletion, isZeroSubagentUsage, subagentTerminalStatus, type RuntimeSubagentRunMetadata } from "../tools/subagent/runtime";
|
|
11
11
|
import type { RuntimeTelemetryCoordinator } from "./coordinator";
|
|
12
12
|
import { summarizeModelUsage } from "./model-usage";
|
|
13
13
|
import type { RuntimeEventOutboxRepository } from "./runtime-event-repository";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Runtime Tools
|
|
2
|
+
|
|
3
|
+
工具按功能归组。根 `index.ts` 只维护包内统一导出;功能目录的 `index.ts` 定义或组合该功能的 Pi Tool。
|
|
4
|
+
|
|
5
|
+
| 目录 | 职责 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `runtime/` | 公共工具契约、Harness 编译、执行决策、审批与客户端交互结算 |
|
|
8
|
+
| `adapters/` | AI SDK ↔ Pi 转换,以及外部工具声明到 Pi Candidate 的适配 |
|
|
9
|
+
| `codemode/` | `execute` 和 `browser_execute` 的 Pi Tool 定义、Cloudflare 执行适配、Workspace Connector |
|
|
10
|
+
| `assistant/` | 用户问答、计划、后续建议、记忆与时间工具 |
|
|
11
|
+
| `workspace/` | 持久 Workspace 文件操作、写入串行化与版本操作工具 |
|
|
12
|
+
| `sandbox/` | 临时 Linux Sandbox 的命令、进程和显式文件发布工具 |
|
|
13
|
+
| `skill/` | Skill 工具组合、资源读取与复制、脚本运行与输出投影 |
|
|
14
|
+
| `subagent/` | 子 Agent 工具、权限、运行记录、类型和临时 Agent 协调 |
|
|
15
|
+
| `schedule/` | 定时任务的 Pi Tool 定义 |
|
|
16
|
+
| `web/` | 网页获取与 Provider-native 搜索 |
|
|
17
|
+
| `mcp/` | MCP Tool 接入及使用 MCP Session 的 Connector Gateway |
|
|
18
|
+
| `extension/` | 已加载 Extension 的发现工具;Extension 装载仍归 `context/extension/` |
|
|
19
|
+
|
|
20
|
+
## Code Mode
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
codemode/index.ts Pi Tool 定义、执行级别与调用截止
|
|
24
|
+
|
|
|
25
|
+
v
|
|
26
|
+
RuntimeCodeExecutionPort 内部执行接口
|
|
27
|
+
|
|
|
28
|
+
v
|
|
29
|
+
codemode/adapter.ts Cloudflare 执行器组装、描述与结果适配
|
|
30
|
+
|
|
|
31
|
+
+-- workspace-connector.ts state.* 方法、发现与校验
|
|
32
|
+
+-- ../adapters/pi-to-ai.ts 现有 Pi 工具到 Cloudflare ToolSet 的转换
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`pi-to-ai.ts` 也被 Skill 脚本执行使用,因此放在共用 `adapters/` 中。工具声明仍以 Pi 为唯一事实源。
|
|
36
|
+
|
|
37
|
+
## 修改位置
|
|
38
|
+
|
|
39
|
+
- 修改某个工具:进入对应功能目录,测试与实现放在一起。
|
|
40
|
+
- 修改所有工具共用的执行策略:进入 `runtime/`,不要复制到每个工具。
|
|
41
|
+
- 修改外部协议转换:进入 `adapters/`,不新增另一份工具定义。
|
|
42
|
+
- 小功能可以只有 `index.ts`,不强制拆分 Adapter、Service 或 Repository。
|
|
43
|
+
- `runtime/result.ts` 只共享结果包装与进度报告;`assistant/candidate.ts` 只共享助手工具已有的元数据默认值。
|
|
44
|
+
- 包的公共导出名及 `@springbrand/agent-runtime/declared-tool` 子路径保持不变。内部路径变更需要同步 Vitest 测试池配置。
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
// 本文件把 ai-sdk Tool 转成 Pi AgentTool。
|
|
2
2
|
// `@cloudflare/think`、Agents Skills 和 Workspace 等上游工厂生成 ai-sdk Tool 时调用这个边界。
|
|
3
|
-
// 转换只保留在这里,才能让后续治理和结算始终看到同一种 AgentTool 结果;核心术语见
|
|
3
|
+
// 转换只保留在这里,才能让后续治理和结算始终看到同一种 AgentTool 结果;核心术语见 `src/index.ts` 入口说明。
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
AgentTool,
|
|
7
|
-
AgentToolResult,
|
|
8
|
-
} from "@earendil-works/pi-agent-core";
|
|
5
|
+
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
9
6
|
import { asSchema, type Tool as AiTool } from "ai";
|
|
10
|
-
import { serializeOutput } from "
|
|
7
|
+
import { serializeOutput } from "../../lib/artifacts";
|
|
11
8
|
|
|
12
9
|
/** 控制一个 ai-sdk Tool 如何投影到 Pi AgentTool。 */
|
|
13
10
|
export interface AiToolAdapterOptions {
|
|
@@ -141,9 +138,9 @@ export function aiToolToPi(
|
|
|
141
138
|
.toModelOutput;
|
|
142
139
|
const content = typeof toModelOutput === "function"
|
|
143
140
|
? modelContent(await (toModelOutput as (input: {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
input: unknown;
|
|
142
|
+
output: unknown;
|
|
143
|
+
}) => unknown)({ input: params, output: raw }))
|
|
147
144
|
: null;
|
|
148
145
|
return {
|
|
149
146
|
content: content ?? [{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { PiToolCandidate } from "
|
|
2
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 一个由 Runtime 之外声明、由 Runtime 之外执行的工具。
|
|
@@ -15,7 +15,7 @@ import type { PiToolCandidate } from "./compiler";
|
|
|
15
15
|
* 本模块只引入类型,因此可经 `@springbrand/agent-runtime/declared-tool` 单独导入:
|
|
16
16
|
* 声明方在 Node 下也能装配自己的工具,不必为此加载 Workers 运行时。
|
|
17
17
|
*
|
|
18
|
-
* `Pi`、`Tool Candidate` 与 `Runtime` 等核心术语见
|
|
18
|
+
* `Pi`、`Tool Candidate` 与 `Runtime` 等核心术语见 `src/index.ts`。
|
|
19
19
|
*/
|
|
20
20
|
export interface PiDeclaredTool {
|
|
21
21
|
/** 声明方自己的工具名,用于结果详情与诊断,不一定是模型看到的名字。 */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
3
|
-
import {
|
|
4
|
-
import type { PiToolCandidate } from "
|
|
3
|
+
import { tool as aiTool, jsonSchema, type ToolSet } from "ai";
|
|
4
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
5
5
|
|
|
6
6
|
interface NestedToolOptions {
|
|
7
7
|
readonly fallbackToolCallId?: (name: string) => string;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
|
|
2
|
+
import { modelAttachmentText } from "../../conversation/message/conversion";
|
|
3
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
4
|
+
import { candidate } from "./candidate";
|
|
5
|
+
|
|
6
|
+
const askUserResponseTypes = [
|
|
7
|
+
"text",
|
|
8
|
+
"single_select",
|
|
9
|
+
"multi_select",
|
|
10
|
+
"attachment",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
const askUserQuestion = Type.Object({
|
|
14
|
+
question: Type.String({
|
|
15
|
+
minLength: 1,
|
|
16
|
+
description: "The question or file request to put to the user.",
|
|
17
|
+
}),
|
|
18
|
+
responseType: StringEnum(askUserResponseTypes, {
|
|
19
|
+
description:
|
|
20
|
+
"Required response UI: text, single_select, multi_select, or attachment.",
|
|
21
|
+
}),
|
|
22
|
+
options: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
|
|
23
|
+
minItems: 2,
|
|
24
|
+
maxItems: 6,
|
|
25
|
+
uniqueItems: true,
|
|
26
|
+
description:
|
|
27
|
+
"Required only for single_select or multi_select. Provide 2-6 distinct options; omit for text and attachment.",
|
|
28
|
+
})),
|
|
29
|
+
allowCustom: Type.Optional(Type.Boolean({
|
|
30
|
+
description:
|
|
31
|
+
"For single_select or multi_select, whether the user may also add free text. Defaults to false.",
|
|
32
|
+
})),
|
|
33
|
+
required: Type.Optional(Type.Boolean({
|
|
34
|
+
default: false,
|
|
35
|
+
description:
|
|
36
|
+
"Whether the user must answer this question to submit. Defaults to false.",
|
|
37
|
+
})),
|
|
38
|
+
}, { additionalProperties: false });
|
|
39
|
+
|
|
40
|
+
const askUserParameters = Type.Object({
|
|
41
|
+
questions: Type.Array(askUserQuestion, {
|
|
42
|
+
minItems: 1,
|
|
43
|
+
maxItems: 10,
|
|
44
|
+
description:
|
|
45
|
+
"All user decisions and files needed to continue. Every question requires responseType. At most one question may use attachment.",
|
|
46
|
+
}),
|
|
47
|
+
}, { additionalProperties: false });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `ask_user` 的客户端响应体。
|
|
51
|
+
*
|
|
52
|
+
* `answers` 与 Tool 输入的 `questions` 按下标一一对应。
|
|
53
|
+
* 刻意用结构化数组而不是拼好的字符串 —— 选项本身可能含分隔符,拼了再拆是有损的。
|
|
54
|
+
*/
|
|
55
|
+
type AskUserInput = Static<typeof askUserParameters>;
|
|
56
|
+
|
|
57
|
+
type AskUserFile = {
|
|
58
|
+
readonly type: "file";
|
|
59
|
+
readonly url: string;
|
|
60
|
+
readonly filename: string;
|
|
61
|
+
readonly mediaType: string;
|
|
62
|
+
readonly providerMetadata?: unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type AskUserResponse = {
|
|
66
|
+
readonly answers: ReadonlyArray<{
|
|
67
|
+
readonly selections: readonly string[];
|
|
68
|
+
readonly text: string;
|
|
69
|
+
readonly attachments: readonly AskUserFile[];
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
75
|
+
? value as Record<string, unknown>
|
|
76
|
+
: null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function questionsOf(value: unknown): AskUserInput["questions"] | null {
|
|
80
|
+
const questions = recordOf(value)?.questions;
|
|
81
|
+
if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const normalized: AskUserInput["questions"] = [];
|
|
85
|
+
let attachmentQuestions = 0;
|
|
86
|
+
for (const value of questions) {
|
|
87
|
+
const question = recordOf(value);
|
|
88
|
+
if (!question || typeof question.question !== "string" || !question.question) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (Object.keys(question).some((key) =>
|
|
92
|
+
key !== "question" && key !== "responseType" && key !== "options" &&
|
|
93
|
+
key !== "allowCustom" && key !== "required"
|
|
94
|
+
)) return null;
|
|
95
|
+
if (
|
|
96
|
+
question.required !== undefined && typeof question.required !== "boolean"
|
|
97
|
+
) return null;
|
|
98
|
+
const required = question.required === true;
|
|
99
|
+
let responseType = question.responseType;
|
|
100
|
+
if (
|
|
101
|
+
typeof responseType !== "string" ||
|
|
102
|
+
!askUserResponseTypes.includes(
|
|
103
|
+
responseType as typeof askUserResponseTypes[number],
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const normalizedQuestion = {
|
|
109
|
+
question: question.question,
|
|
110
|
+
responseType: responseType as typeof askUserResponseTypes[number],
|
|
111
|
+
required,
|
|
112
|
+
};
|
|
113
|
+
if (responseType === "attachment") {
|
|
114
|
+
attachmentQuestions += 1;
|
|
115
|
+
if (
|
|
116
|
+
attachmentQuestions > 1 ||
|
|
117
|
+
question.options !== undefined ||
|
|
118
|
+
question.allowCustom !== undefined
|
|
119
|
+
) return null;
|
|
120
|
+
normalized.push(normalizedQuestion);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (responseType === "text") {
|
|
124
|
+
if (
|
|
125
|
+
question.options !== undefined ||
|
|
126
|
+
question.allowCustom !== undefined
|
|
127
|
+
) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
normalized.push(normalizedQuestion);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
!Array.isArray(question.options) ||
|
|
135
|
+
question.options.length < 2 ||
|
|
136
|
+
question.options.length > 6 ||
|
|
137
|
+
!question.options.every((option) =>
|
|
138
|
+
typeof option === "string" && option.length > 0
|
|
139
|
+
) ||
|
|
140
|
+
new Set(question.options).size !== question.options.length ||
|
|
141
|
+
(question.allowCustom !== undefined &&
|
|
142
|
+
typeof question.allowCustom !== "boolean")
|
|
143
|
+
) return null;
|
|
144
|
+
normalized.push({
|
|
145
|
+
...normalizedQuestion,
|
|
146
|
+
options: question.options,
|
|
147
|
+
...(question.allowCustom === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { allowCustom: question.allowCustom }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return normalized;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function prepareAskUserArguments(input: unknown): AskUserInput {
|
|
156
|
+
const value = recordOf(input);
|
|
157
|
+
if (!value) throw new Error("Invalid ask_user questions");
|
|
158
|
+
let questions = value.questions;
|
|
159
|
+
if (typeof questions === "string") {
|
|
160
|
+
try {
|
|
161
|
+
questions = JSON.parse(questions);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error("Invalid ask_user questions");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const prepared = { ...value, questions };
|
|
167
|
+
if (!questionsOf(prepared)) throw new Error("Invalid ask_user questions");
|
|
168
|
+
return prepared as AskUserInput;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 手写校验而不是拉 TypeBox compiler:这里只需要判真假,
|
|
172
|
+
// 且校验位于 Worker 响应信任边界,不应为每次回答编译 schema。
|
|
173
|
+
function isAskUserResponse(input: unknown, value: unknown): boolean {
|
|
174
|
+
const questions = questionsOf(input);
|
|
175
|
+
const answers = recordOf(value)?.answers;
|
|
176
|
+
if (!questions || !Array.isArray(answers) || answers.length !== questions.length) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return questions.every((question, index) => {
|
|
181
|
+
const answer = recordOf(answers[index]);
|
|
182
|
+
if (
|
|
183
|
+
!answer ||
|
|
184
|
+
!Array.isArray(answer.selections) ||
|
|
185
|
+
typeof answer.text !== "string" ||
|
|
186
|
+
!Array.isArray(answer.attachments) ||
|
|
187
|
+
Object.keys(answer).some((key) =>
|
|
188
|
+
key !== "selections" && key !== "text" && key !== "attachments"
|
|
189
|
+
)
|
|
190
|
+
) return false;
|
|
191
|
+
if (!answer.selections.every((item) => typeof item === "string")) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
if (answer.text.length > 2_000) return false;
|
|
195
|
+
if (!answer.attachments.every((value) => {
|
|
196
|
+
const file = recordOf(value);
|
|
197
|
+
return Boolean(
|
|
198
|
+
file &&
|
|
199
|
+
file.type === "file" &&
|
|
200
|
+
typeof file.url === "string" && file.url.length > 0 &&
|
|
201
|
+
typeof file.filename === "string" && file.filename.length > 0 &&
|
|
202
|
+
typeof file.mediaType === "string" && file.mediaType.length > 0 &&
|
|
203
|
+
(file.providerMetadata === undefined || recordOf(file.providerMetadata)) &&
|
|
204
|
+
Object.keys(file).every((key) =>
|
|
205
|
+
key === "type" || key === "url" || key === "filename" ||
|
|
206
|
+
key === "mediaType" || key === "providerMetadata"
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
})) return false;
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
answer.selections.length === 0 &&
|
|
213
|
+
!answer.text.trim() &&
|
|
214
|
+
answer.attachments.length === 0
|
|
215
|
+
) return question.required !== true;
|
|
216
|
+
|
|
217
|
+
if (question.responseType === "attachment") {
|
|
218
|
+
return answer.selections.length === 0 && !answer.text &&
|
|
219
|
+
answer.attachments.length >= 1 && answer.attachments.length <= 8;
|
|
220
|
+
}
|
|
221
|
+
if (answer.attachments.length > 0) return false;
|
|
222
|
+
if (question.responseType === "text") {
|
|
223
|
+
return answer.selections.length === 0 && answer.text.trim().length > 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const options = question.options ?? [];
|
|
227
|
+
if (answer.selections.some((selection) => !options.includes(selection))) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (new Set(answer.selections).size !== answer.selections.length) return false;
|
|
231
|
+
if (
|
|
232
|
+
question.responseType === "single_select" &&
|
|
233
|
+
answer.selections.length > 1
|
|
234
|
+
) return false;
|
|
235
|
+
if (!question.allowCustom && answer.text.trim()) return false;
|
|
236
|
+
return answer.selections.length > 0 || answer.text.trim().length > 0;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function askUserPiToolCandidate(): PiToolCandidate {
|
|
241
|
+
return {
|
|
242
|
+
...candidate({
|
|
243
|
+
name: "ask_user",
|
|
244
|
+
label: "Ask user",
|
|
245
|
+
description:
|
|
246
|
+
"Ask all 1-10 user questions needed to continue in one call. Required input for every question: question and responseType; required defaults to false, so set it to true only when progress cannot continue without an answer. Use responseType text for free text, single_select or multi_select with 2-6 distinct options for choices, and attachment for files. At most one attachment question is allowed and it accepts multiple files. Do not add file-specific fields such as accept or tell the user to use the normal chat composer. The answers return as this tool's result, so continue the same turn after calling it.",
|
|
247
|
+
parameters: askUserParameters,
|
|
248
|
+
prepareArguments: prepareAskUserArguments,
|
|
249
|
+
// 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
|
|
250
|
+
// 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
|
|
251
|
+
async execute() {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"ask_user is client-settled and must not execute on the server",
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
interaction: {
|
|
258
|
+
validateResponse: isAskUserResponse,
|
|
259
|
+
settle: (input, response) => {
|
|
260
|
+
const questions = questionsOf(input)!;
|
|
261
|
+
const responseAnswers = (response as AskUserResponse).answers;
|
|
262
|
+
const answers = questions.map((question, index) => {
|
|
263
|
+
const answer = responseAnswers[index]!;
|
|
264
|
+
return {
|
|
265
|
+
question: question.question,
|
|
266
|
+
selections: [...answer.selections],
|
|
267
|
+
text: answer.text.trim(),
|
|
268
|
+
attachments: answer.attachments.map((file) => {
|
|
269
|
+
const springbrand = recordOf(
|
|
270
|
+
recordOf(file.providerMetadata)?.springbrand,
|
|
271
|
+
);
|
|
272
|
+
return {
|
|
273
|
+
name: file.filename,
|
|
274
|
+
mediaType: file.mediaType,
|
|
275
|
+
size: typeof springbrand?.size === "number"
|
|
276
|
+
? springbrand.size
|
|
277
|
+
: 0,
|
|
278
|
+
cdnUrl: typeof springbrand?.cdnUrl === "string"
|
|
279
|
+
? springbrand.cdnUrl
|
|
280
|
+
: file.url,
|
|
281
|
+
workspacePath: typeof springbrand?.path === "string"
|
|
282
|
+
? springbrand.path
|
|
283
|
+
: "",
|
|
284
|
+
attachmentId: typeof springbrand?.attachmentId === "string"
|
|
285
|
+
? springbrand.attachmentId
|
|
286
|
+
: "",
|
|
287
|
+
contentVersion:
|
|
288
|
+
typeof springbrand?.contentVersion === "string"
|
|
289
|
+
? springbrand.contentVersion
|
|
290
|
+
: "",
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: `The user answered:\n${answers.map((answer, index) => {
|
|
299
|
+
if (answer.attachments.length > 0) {
|
|
300
|
+
const responseAnswer = responseAnswers[index]!;
|
|
301
|
+
return `${index + 1}. ${answer.question}\n${responseAnswer.attachments
|
|
302
|
+
.map((file) => ` - ${modelAttachmentText(file)}`)
|
|
303
|
+
.join("\n")}`;
|
|
304
|
+
}
|
|
305
|
+
const parts = [
|
|
306
|
+
...answer.selections,
|
|
307
|
+
...(answer.text ? [answer.text] : []),
|
|
308
|
+
];
|
|
309
|
+
return `${index + 1}. ${answer.question}\n ${parts.join(" / ")}`;
|
|
310
|
+
}).join("\n")}`,
|
|
311
|
+
}],
|
|
312
|
+
details: { answers },
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { type TSchema } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
4
|
+
|
|
5
|
+
export function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidate {
|
|
6
|
+
return {
|
|
7
|
+
owner: "runtime-base",
|
|
8
|
+
requiredExecutionLevel: "safe",
|
|
9
|
+
source: "action",
|
|
10
|
+
tool: {
|
|
11
|
+
...tool,
|
|
12
|
+
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
13
|
+
} as AgentTool<T>,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
3
|
+
import { result } from "../runtime/result";
|
|
4
|
+
import { candidate } from "./candidate";
|
|
5
|
+
|
|
6
|
+
const suggestFollowupsParameters = Type.Object({
|
|
7
|
+
items: Type.Array(Type.String({
|
|
8
|
+
description: "A follow-up the user could ask next, phrased as a request.",
|
|
9
|
+
}), {
|
|
10
|
+
minItems: 2,
|
|
11
|
+
maxItems: 4,
|
|
12
|
+
description: "2-4 concrete, distinct follow-up directions.",
|
|
13
|
+
}),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
|
|
16
|
+
export function suggestFollowupsPiToolCandidate(): PiToolCandidate {
|
|
17
|
+
return {
|
|
18
|
+
...candidate({
|
|
19
|
+
name: "suggest_followups",
|
|
20
|
+
label: "Suggest follow-ups",
|
|
21
|
+
description:
|
|
22
|
+
"Offer the user 2-4 optional follow-up directions after completing a substantive task (a report, an analysis, a multi-step job). Call this at most once, after all work and other Tool calls are complete. It MUST be the last Tool call before the final answer. After it returns, write the complete final answer and make no further Tool calls. Do NOT call it for small talk, quick answers, or while a task is still in progress.",
|
|
23
|
+
parameters: suggestFollowupsParameters,
|
|
24
|
+
async execute(_toolCallId, input) {
|
|
25
|
+
return result({ noted: true, count: input.items.length });
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
unattendedExecution: {
|
|
29
|
+
capability: "runtime-ui",
|
|
30
|
+
operation: "suggest-followups",
|
|
31
|
+
},
|
|
32
|
+
exposureMode: "direct",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
2
|
+
import { webSearchPiToolCandidate } from "../web/search";
|
|
3
|
+
import type { WebSearch } from "../web/search/api";
|
|
4
|
+
import { askUserPiToolCandidate } from "./ask-user";
|
|
5
|
+
import { suggestFollowupsPiToolCandidate } from "./followups";
|
|
6
|
+
import { updatePlanPiToolCandidate } from "./plan";
|
|
7
|
+
|
|
8
|
+
export { memoryPiToolCandidate } from "./memory";
|
|
9
|
+
export { normalizeUpdatePlanArguments } from "./plan";
|
|
10
|
+
export * from "./time";
|
|
11
|
+
|
|
12
|
+
export function basePiToolCandidates(webSearch?: WebSearch): PiToolCandidate[] {
|
|
13
|
+
return [
|
|
14
|
+
askUserPiToolCandidate(),
|
|
15
|
+
suggestFollowupsPiToolCandidate(),
|
|
16
|
+
updatePlanPiToolCandidate(),
|
|
17
|
+
...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import { estimateStringTokens } from "../../lib/token-estimate";
|
|
4
|
+
import type { RuntimeMemoryPort } from "../../config/ports";
|
|
5
|
+
import type { RuntimeMemoryProfile } from "../../config/profile";
|
|
6
|
+
import type { PiToolCandidate } from "../runtime/contracts";
|
|
7
|
+
import { result } from "../runtime/result";
|
|
8
|
+
|
|
9
|
+
const setContextParameters = Type.Object({
|
|
10
|
+
label: StringEnum(["memory", "preferences"] as const, {
|
|
11
|
+
description:
|
|
12
|
+
"Context block to update: memory for durable facts and active context, preferences for tone and workflow choices.",
|
|
13
|
+
}),
|
|
14
|
+
content: Type.String({ description: "Text to store in the selected context block." }),
|
|
15
|
+
action: Type.Optional(StringEnum(["replace", "append"] as const, {
|
|
16
|
+
description: 'Whether to replace the block or append to it. Defaults to "replace".',
|
|
17
|
+
})),
|
|
18
|
+
}, { additionalProperties: false });
|
|
19
|
+
|
|
20
|
+
/** Writable hot-memory Tool restored from Think Session context blocks. */
|
|
21
|
+
export function memoryPiToolCandidate(
|
|
22
|
+
memory: RuntimeMemoryPort,
|
|
23
|
+
profile: Pick<
|
|
24
|
+
RuntimeMemoryProfile,
|
|
25
|
+
"memoryTokens" | "preferencesTokens"
|
|
26
|
+
>,
|
|
27
|
+
): PiToolCandidate {
|
|
28
|
+
const queues = new Map<string, Promise<void>>();
|
|
29
|
+
const tool: AgentTool<typeof setContextParameters> = {
|
|
30
|
+
name: "set_context",
|
|
31
|
+
label: "Set context",
|
|
32
|
+
description:
|
|
33
|
+
"Replace or append durable working context. Use memory for facts and active context; use preferences for tone, format, tools, and workflow preferences.",
|
|
34
|
+
parameters: setContextParameters,
|
|
35
|
+
async execute(_toolCallId, { label, content, action = "replace" }) {
|
|
36
|
+
const run = async () => {
|
|
37
|
+
const existing = action === "append"
|
|
38
|
+
? await memory.get(label) ?? ""
|
|
39
|
+
: "";
|
|
40
|
+
const separator = existing && !content.startsWith("\n") ? "\n" : "";
|
|
41
|
+
const updated = `${existing}${separator}${content}`;
|
|
42
|
+
const maxTokens = label === "memory"
|
|
43
|
+
? profile.memoryTokens
|
|
44
|
+
: profile.preferencesTokens;
|
|
45
|
+
const tokens = estimateStringTokens(updated);
|
|
46
|
+
if (tokens > maxTokens) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Block "${label}" exceeds maxTokens: ${tokens} > ${maxTokens}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
await memory.set(label, updated);
|
|
52
|
+
return result({ label, action, tokens, maxTokens });
|
|
53
|
+
};
|
|
54
|
+
const tail = queues.get(label) ?? Promise.resolve();
|
|
55
|
+
const output = tail.then(run);
|
|
56
|
+
const next = output.then(
|
|
57
|
+
() => { },
|
|
58
|
+
() => { },
|
|
59
|
+
);
|
|
60
|
+
queues.set(label, next);
|
|
61
|
+
void next.then(() => {
|
|
62
|
+
if (queues.get(label) === next) queues.delete(label);
|
|
63
|
+
});
|
|
64
|
+
return output;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
owner: "runtime-memory",
|
|
69
|
+
requiredExecutionLevel: "safe",
|
|
70
|
+
source: "action",
|
|
71
|
+
tool,
|
|
72
|
+
};
|
|
73
|
+
}
|