feat(agent): Pi 子进程接入(A3.3)
新增 server/agent/pi/runtime.ts:用官方 RpcClient 拉起 pi --mode rpc,一个会话一个进程(ADR-0006)。启动参数带 --no-builtin-tools / --no-extensions / --no-skills / --no-context-files,Pi 自带的 read/bash/edit/write 全部关掉,工具只能来自能力目录(扩展桥在 A3.11 接)。密钥经环境变量注入子进程(不进命令行,避免进程列表泄露);Pi 自己的 session 文件放 ~/.aps/pi-sessions 只作同引擎恢复,会话历史仍归平台。事件映射:message_update(text_delta)→text、tool_execution_start/end→tool_call/tool_result、message_end(assistant, stopReason=error)→error 并按六分类归一、agent_settled→turn_end。配置层加引擎选择:有密钥自动用 Pi、否则假引擎,可用 APS_ENGINE 覆盖;要求 Pi 但没有密钥时明确回退并告警。未配置密钥时 createSession 快速失败并给出配置指引,不再静默挂住。测试 56 → 60(59 通过 + 1 条需 APS_PI_INTEGRATION=1 的真实调用默认跳过);用假密钥实测:401 在 340ms 内被归到 forbidden,随后正常收尾。过程中修掉自身缺陷:错误回合的 finishReason 被 turn_end 覆盖回 stop,改为取最严重结果。
This commit is contained in:
parent
1dbed5be7c
commit
2c0cfb85d0
|
|
@ -26,6 +26,10 @@ APS_PORT=5199
|
||||||
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
|
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
|
||||||
# APS_DB_PATH=
|
# APS_DB_PATH=
|
||||||
|
|
||||||
|
# ---- 引擎选择 ----
|
||||||
|
# 留空 = 有密钥就用 Pi、没密钥用假引擎;也可显式写 fake | pi
|
||||||
|
# APS_ENGINE=
|
||||||
|
|
||||||
# ---- 开发期开关 ----
|
# ---- 开发期开关 ----
|
||||||
# 1=注册假 Provider(用于联调与演示;默认不注册,能力清单为空)
|
# 1=注册假 Provider(用于联调与演示;默认不注册,能力清单为空)
|
||||||
# APS_DEV_PROVIDER=1
|
# APS_DEV_PROVIDER=1
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{"type":"session","version":3,"id":"raw-probe","timestamp":"2026-09-28T12:18:49.579Z","cwd":"D:\\workspace\\github\\aps-agent"}
|
||||||
|
{"type":"model_change","id":"c5ce29c8","parentId":null,"timestamp":"2026-09-28T12:18:49.620Z","provider":"deepseek","modelId":"deepseek-chat"}
|
||||||
|
{"type":"thinking_level_change","id":"d7ce43d9","parentId":"c5ce29c8","timestamp":"2026-09-28T12:18:49.620Z","thinkingLevel":"high"}
|
||||||
|
{"type":"message","id":"9daf9ab7","parentId":"d7ce43d9","timestamp":"2026-09-28T12:18:49.642Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.","tools":"<tools>\n(none)\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n</tools>","rules":"<rules>\n- Be concise in your responses\n- Show file paths clearly when working with files\n</rules>","docs":"<docs>\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: D:\\workspace\\github\\aps-agent\\node_modules\\.pnpm\\@earendil-works+pi-coding-agent@0.87.1_ws@8.22.0\\node_modules\\@earendil-works\\pi-coding-agent\\README.md\n- Additional docs: D:\\workspace\\github\\aps-agent\\node_modules\\.pnpm\\@earendil-works+pi-coding-agent@0.87.1_ws@8.22.0\\node_modules\\@earendil-works\\pi-coding-agent\\docs\n- Examples: D:\\workspace\\github\\aps-agent\\node_modules\\.pnpm\\@earendil-works+pi-coding-agent@0.87.1_ws@8.22.0\\node_modules\\@earendil-works\\pi-coding-agent\\examples (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)\n</docs>","cwd":"<cwd>\nD:/workspace/github/aps-agent\n</cwd>"},"timestamp":1790597929641}}
|
||||||
|
{"type":"message","id":"421a57b6","parentId":"9daf9ab7","timestamp":"2026-09-28T12:18:49.642Z","message":{"role":"user","content":[{"type":"text","text":"用一句话回答:1+1 等于几"}],"timestamp":1790597929640}}
|
||||||
|
{"type":"message","id":"5ab3ce68","parentId":"421a57b6","timestamp":"2026-09-28T12:18:49.873Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","timestamp":1790597929656,"errorMessage":"401: {\"message\":\"Authentication Fails, Your api key: ****-key is invalid (request_id: 91c7a4c1-9f68-4283-9b12-a1c985d6b703)\",\"type\":\"authentication_error\",\"param\":null,\"code\":\"invalid_request_error\"}"}}
|
||||||
|
{"type":"message","id":"290decc4","parentId":"5ab3ce68","timestamp":"2026-09-28T12:19:10.278Z","message":{"role":"user","content":[{"type":"text","text":"用一句话回答:1+1 等于几"}],"timestamp":1790597950276}}
|
||||||
|
{"type":"message","id":"a69d0b1e","parentId":"290decc4","timestamp":"2026-09-28T12:19:10.444Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","timestamp":1790597950292,"errorMessage":"401: {\"message\":\"Authentication Fails, Your api key: ****-key is invalid (request_id: 37c34f4b-4e59-42c6-970f-23b3cb2958ea)\",\"type\":\"authentication_error\",\"param\":null,\"code\":\"invalid_request_error\"}"}}
|
||||||
1022
pnpm-lock.yaml
1022
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -9,4 +9,7 @@ packages:
|
||||||
|
|
||||||
# pnpm 11:允许执行安装脚本的依赖。esbuild 是 vite 的构建依赖,需要 postinstall。
|
# pnpm 11:允许执行安装脚本的依赖。esbuild 是 vite 的构建依赖,需要 postinstall。
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
# Pi 依赖链带 postinstall;装它们是 Pi 官方推荐的接入方式(ADR-0006)
|
||||||
|
'@google/genai': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
protobufjs: true
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,19 @@ test('配置文件路径:默认在用户目录,可用 APS_CONFIG_FILE 覆盖
|
||||||
assert.equal(configFilePath({ env: {}, homeDir: 'C:/Users/test' }).replace(/\\/g, '/'), 'C:/Users/test/.aps/config.json');
|
assert.equal(configFilePath({ env: {}, homeDir: 'C:/Users/test' }).replace(/\\/g, '/'), 'C:/Users/test/.aps/config.json');
|
||||||
assert.equal(configFilePath({ env: { APS_CONFIG_FILE: 'D:/cfg/aps.json' }, homeDir: 'C:/Users/test' }), 'D:/cfg/aps.json');
|
assert.equal(configFilePath({ env: { APS_CONFIG_FILE: 'D:/cfg/aps.json' }, homeDir: 'C:/Users/test' }), 'D:/cfg/aps.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('引擎选择:没密钥用假引擎,有密钥自动选 Pi,可显式覆盖', async () => {
|
||||||
|
await withConfigFile(undefined, async ({ dir, file }) => {
|
||||||
|
const noKey = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
|
||||||
|
assert.equal(noKey.engine.kind, 'fake', '没有密钥时应回退到假引擎');
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = JSON.stringify({ llm: { apiKey: 'sk-file-123' } });
|
||||||
|
await withConfigFile(content, async ({ dir, file }) => {
|
||||||
|
const withKey = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
|
||||||
|
assert.equal(withKey.engine.kind, 'pi', '有密钥时应自动选 Pi');
|
||||||
|
|
||||||
|
const forced = loadConfig({ env: { APS_CONFIG_FILE: file, APS_ENGINE: 'fake' }, homeDir: dir });
|
||||||
|
assert.equal(forced.engine.kind, 'fake', '显式配置应覆盖自动选择');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Pi 子进程接线探测(开发用)。
|
||||||
|
*
|
||||||
|
* 拉起 Pi、发一句话、把映射后的事件打出来。没有配置密钥时也能跑:
|
||||||
|
* 它会走到"模型不可用",正好用来验证进程、协议与错误映射这三段接线。
|
||||||
|
*
|
||||||
|
* 用法:node scripts/pi-probe.mjs [提问]
|
||||||
|
*/
|
||||||
|
import { createPiRuntime } from '@aps/server/agent/pi/runtime';
|
||||||
|
|
||||||
|
const prompt = process.argv[2] ?? '用一句话回答:1+1 等于几';
|
||||||
|
const runtime = createPiRuntime({ provider: process.env.APS_LLM_PROVIDER ?? 'deepseek', model: process.env.APS_LLM_MODEL ?? 'deepseek-chat', apiKey: process.env.APS_LLM_API_KEY ?? '', turnTimeoutMs: 30_000 });
|
||||||
|
|
||||||
|
console.log(`引擎:${runtime.name}`);
|
||||||
|
const session = await runtime.createSession({ sessionId: 'probe-1', subject: { userId: 'probe', tenantId: 'probe' }, projectId: null, tools: [] });
|
||||||
|
console.log(`会话已创建:${session.id}`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
for await (const event of session.send(prompt, { signal: controller.signal })) {
|
||||||
|
console.log(`[${String(Date.now() - started).padStart(5)}ms] ${event.type}`, JSON.stringify(event).slice(0, 260));
|
||||||
|
if (event.type === 'turn_end') break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await session.dispose();
|
||||||
|
console.log('子进程已停止');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Pi 运行时用例(任务 A3.3)。
|
||||||
|
*
|
||||||
|
* 1. 未配置密钥时必须快速失败并给出指引(不是静默挂住)
|
||||||
|
* 2. 真实跑一轮:需要 APS_PI_INTEGRATION=1 且提供密钥(网络调用,默认跳过)
|
||||||
|
* —— 密钥有效时断言收到文本;无效时断言错误被归到六分类之一;两者都必须以 turn_end 收尾
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { classifyPiFailure } from '@aps/server/agent/pi/runtime';
|
||||||
|
|
||||||
|
const integrationEnabled = process.env.APS_PI_INTEGRATION === '1';
|
||||||
|
const apiKey = process.env.APS_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? '';
|
||||||
|
|
||||||
|
test('未配置模型密钥时快速失败并给出配置指引', async () => {
|
||||||
|
// 环境变量也算配置来源,这里临时清掉,确保「真的没有密钥」
|
||||||
|
const saved = process.env.DEEPSEEK_API_KEY;
|
||||||
|
delete process.env.DEEPSEEK_API_KEY;
|
||||||
|
try {
|
||||||
|
const { createPiRuntime } = await import('@aps/server/agent/pi/runtime');
|
||||||
|
const runtime = createPiRuntime({ provider: 'deepseek', model: 'deepseek-chat', apiKey: '' });
|
||||||
|
await assert.rejects(
|
||||||
|
() => runtime.createSession({ sessionId: 'no-key', subject: { userId: 'u', tenantId: 't' }, projectId: null, tools: [] }),
|
||||||
|
/未配置模型密钥/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (saved !== undefined) process.env.DEEPSEEK_API_KEY = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('错误文本按六分类归一', () => {
|
||||||
|
assert.equal(classifyPiFailure('401: Authentication Fails'), 'forbidden');
|
||||||
|
assert.equal(classifyPiFailure('429 rate limit exceeded'), 'rate_limited');
|
||||||
|
assert.equal(classifyPiFailure('connect ETIMEDOUT'), 'unavailable');
|
||||||
|
assert.equal(classifyPiFailure('something odd'), 'unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('真实跑一轮:Pi 子进程接入', { skip: !integrationEnabled ? '需要 APS_PI_INTEGRATION=1 与密钥(会真实调用模型)' : false }, async () => {
|
||||||
|
const { createPiRuntime } = await import('@aps/server/agent/pi/runtime');
|
||||||
|
const runtime = createPiRuntime({ provider: 'deepseek', model: 'deepseek-chat', apiKey, turnTimeoutMs: 60_000 });
|
||||||
|
const session = await runtime.createSession({
|
||||||
|
sessionId: `it-${Date.now()}`,
|
||||||
|
subject: { userId: 'it', tenantId: 'it' },
|
||||||
|
projectId: null,
|
||||||
|
tools: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
try {
|
||||||
|
for await (const event of session.send('只用一句话回答:1+1 等于几')) {
|
||||||
|
events.push(event);
|
||||||
|
if (event.type === 'turn_end') break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await session.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = events.map((event) => event.type);
|
||||||
|
assert.ok(types.includes('turn_end'), `必须以 turn_end 收尾,实际:${types.join(',')}`);
|
||||||
|
const failed = events.find((event) => event.type === 'error');
|
||||||
|
const text = events.filter((event) => event.type === 'text').map((event) => event.delta).join('');
|
||||||
|
if (failed) {
|
||||||
|
assert.ok(
|
||||||
|
['invalid_argument', 'forbidden', 'conflict', 'rate_limited', 'unavailable', 'unknown_result'].includes(failed.kind),
|
||||||
|
`错误分类必须合法,实际 ${failed.kind}`,
|
||||||
|
);
|
||||||
|
console.log(`(本轮模型调用失败,已按 ${failed.kind} 归一)`);
|
||||||
|
} else {
|
||||||
|
assert.ok(text.length > 0 || types.includes('message_end'), `既没有文本也没有错误,事件:${types.join(',')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
/**
|
||||||
|
* Pi 引擎适配(任务 A3.3,ADR-0006):把 Pi 作为长驻子进程驱动。
|
||||||
|
*
|
||||||
|
* - 用官方 RpcClient 拉起 `pi --mode rpc`,协议是标准输入输出的 JSONL
|
||||||
|
* - 工具面:启动参数带 --no-builtin-tools / --no-extensions,Pi 自带的 read/bash/edit/write 全部关掉;
|
||||||
|
* 平台的工具由 Pi 侧扩展桥提供(A3.11),扩展还没接之前 Pi 手里没有工具(评测里对应场景跳过)
|
||||||
|
* - 事件面:把 Pi 的会话事件映射成平台的 RuntimeEvent,上层只认平台类型
|
||||||
|
* - 状态:平台保存会话历史;Pi 自己的 session 文件放在平台管理目录里,只作同引擎恢复用(ADR-0003)
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { RpcClient, type JsonAgentSessionEvent } from '@earendil-works/pi-coding-agent';
|
||||||
|
import type { ExecutionReceipt } from '@aps/contracts';
|
||||||
|
|
||||||
|
import type { AgentRuntime, CreateRuntimeSessionInput, RuntimeEvent, RuntimeSession, SendOptions } from '../runtime.js';
|
||||||
|
|
||||||
|
export interface PiRuntimeOptions {
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
cwd?: string;
|
||||||
|
cliPath?: string;
|
||||||
|
/** Pi 自己的会话文件目录(默认 ~/.aps/pi-sessions) */
|
||||||
|
sessionDir?: string;
|
||||||
|
/** 额外的 CLI 参数,慎用:会覆盖不了默认的关闭项,只是追加 */
|
||||||
|
extraArgs?: string[];
|
||||||
|
/** 单回合最长等待(毫秒),超时按引擎错误处理 */
|
||||||
|
turnTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** provider → 环境变量名:密钥走环境,不进命令行(命令行会被进程列表看到) */
|
||||||
|
const API_KEY_ENV: Record<string, string> = {
|
||||||
|
deepseek: 'DEEPSEEK_API_KEY',
|
||||||
|
openai: 'OPENAI_API_KEY',
|
||||||
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
|
google: 'GEMINI_API_KEY',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TURN_TIMEOUT_MS = 120_000;
|
||||||
|
const POLL_MS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RpcClient 默认找的是包里的 dist/cli.js,实际可执行入口在 dist/bundle/cli.js,必须显式给。
|
||||||
|
*
|
||||||
|
* 三种取法依次尝试:ESM 解析(这个包的 exports 只声明了 import 条件,CJS 的 require.resolve 会失败)、
|
||||||
|
* 仓库根下的包目录、server 包下的包目录。
|
||||||
|
*/
|
||||||
|
function defaultCliPath(): string {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
try {
|
||||||
|
const resolver = (import.meta as unknown as { resolve?: (specifier: string) => string }).resolve;
|
||||||
|
const resolved = resolver?.('@earendil-works/pi-coding-agent');
|
||||||
|
if (resolved) candidates.push(join(dirname(fileURLToPath(resolved)), 'bundle', 'cli.js'));
|
||||||
|
} catch {
|
||||||
|
/* 落到下面的候选路径 */
|
||||||
|
}
|
||||||
|
for (const root of [process.cwd(), join(process.cwd(), 'server')]) {
|
||||||
|
candidates.push(join(root, 'node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'bundle', 'cli.js'));
|
||||||
|
}
|
||||||
|
return candidates.find((candidate) => existsSync(candidate)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 Pi 的失败文本归到平台的六分类(实施设计 5) */
|
||||||
|
export function classifyPiFailure(detail: string): 'forbidden' | 'rate_limited' | 'unavailable' | 'unknown_result' {
|
||||||
|
if (/401|403|auth|api key|unauthor/i.test(detail)) return 'forbidden';
|
||||||
|
if (/429|rate.?limit|too many/i.test(detail)) return 'rate_limited';
|
||||||
|
if (/timeout|ETIMEDOUT|ECONNRESET|ENOTFOUND|socket hang up/i.test(detail)) return 'unavailable';
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pi 的事件联合很宽,这里只映射平台需要的部分,其余忽略 */
|
||||||
|
export function mapPiEvent(event: JsonAgentSessionEvent): RuntimeEvent[] {
|
||||||
|
const raw = event as unknown as Record<string, unknown>;
|
||||||
|
const type = String(raw.type ?? '');
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'message_update': {
|
||||||
|
const inner = raw.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||||
|
if (inner && inner.type === 'text_delta' && typeof inner.delta === 'string') {
|
||||||
|
return [{ type: 'text', delta: inner.delta }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
case 'tool_execution_start':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'tool_call',
|
||||||
|
callId: String(raw.toolCallId ?? ''),
|
||||||
|
capabilityId: String(raw.toolName ?? ''),
|
||||||
|
args: raw.args,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case 'tool_execution_end': {
|
||||||
|
const callId = String(raw.toolCallId ?? '');
|
||||||
|
const receipt: ExecutionReceipt = {
|
||||||
|
executionId: callId,
|
||||||
|
status: raw.isError === true ? 'failed' : 'succeeded',
|
||||||
|
result: raw.result,
|
||||||
|
...(raw.isError === true ? { errorKind: 'unknown_result' as const } : {}),
|
||||||
|
};
|
||||||
|
return [{ type: 'tool_result', callId, receipt }];
|
||||||
|
}
|
||||||
|
case 'message_end': {
|
||||||
|
// 模型调用失败不是独立事件:助手消息带 stopReason=error 与 errorMessage(实测形状)
|
||||||
|
const message = raw.message as { role?: string; stopReason?: string; errorMessage?: string } | undefined;
|
||||||
|
if (!message || message.role !== 'assistant') return [];
|
||||||
|
if (message.stopReason !== 'error' && message.errorMessage === undefined) return [];
|
||||||
|
const detail = typeof message.errorMessage === 'string' ? message.errorMessage : '';
|
||||||
|
return [{ type: 'error', kind: classifyPiFailure(detail), message: detail || '模型调用失败(未提供详情)' }];
|
||||||
|
}
|
||||||
|
case 'agent_settled':
|
||||||
|
// Pi 明确表示不会再自动继续(重试、压缩、排队都结束了)
|
||||||
|
return [{ type: 'turn_end', reason: 'stop' }];
|
||||||
|
case 'error': {
|
||||||
|
const reason = raw.reason ?? raw.error;
|
||||||
|
const detail = typeof reason === 'string' ? reason : 'Pi 报错(未提供详情)';
|
||||||
|
return [{ type: 'error', kind: classifyPiFailure(detail), message: detail }];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPiRuntime(options: PiRuntimeOptions = {}): AgentRuntime {
|
||||||
|
const provider = options.provider ?? 'deepseek';
|
||||||
|
const model = options.model ?? 'deepseek-chat';
|
||||||
|
const sessionDir = options.sessionDir ?? join(homedir(), '.aps', 'pi-sessions');
|
||||||
|
const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `pi:${provider}/${model}`,
|
||||||
|
async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value !== undefined) env[key] = value;
|
||||||
|
}
|
||||||
|
if (options.apiKey !== undefined && options.apiKey !== '') {
|
||||||
|
env[API_KEY_ENV[provider] ?? 'DEEPSEEK_API_KEY'] = options.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliPath = options.cliPath ?? defaultCliPath();
|
||||||
|
const client = new RpcClient({
|
||||||
|
...(cliPath === '' ? {} : { cliPath }),
|
||||||
|
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
env,
|
||||||
|
args: [
|
||||||
|
// 工具只来自能力目录:关掉内置工具与自动发现的扩展/技能(A3.11 再接扩展桥)
|
||||||
|
'--no-builtin-tools',
|
||||||
|
'--no-extensions',
|
||||||
|
'--no-skills',
|
||||||
|
'--no-prompt-templates',
|
||||||
|
'--no-context-files',
|
||||||
|
'--session-dir',
|
||||||
|
sessionDir,
|
||||||
|
'--session-id',
|
||||||
|
input.sessionId,
|
||||||
|
...(input.system === undefined ? [] : ['--system-prompt', input.system]),
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((options.apiKey ?? '') === '' && (env[API_KEY_ENV[provider] ?? 'DEEPSEEK_API_KEY'] ?? '') === '') {
|
||||||
|
throw new Error(`Pi 引擎未配置模型密钥:请在 ~/.aps/config.json 的 llm.apiKey 里填写,或设置 ${API_KEY_ENV[provider] ?? 'DEEPSEEK_API_KEY'}(ADR-0011)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `pi-${input.sessionId}`,
|
||||||
|
async *send(text: string, sendOptions?: SendOptions): AsyncIterable<RuntimeEvent> {
|
||||||
|
const queue: RuntimeEvent[] = [];
|
||||||
|
let wake: (() => void) | undefined;
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
|
const unsubscribe = client.onEvent((event) => {
|
||||||
|
queue.push(...mapPiEvent(event));
|
||||||
|
wake?.();
|
||||||
|
wake = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.prompt(text);
|
||||||
|
const deadline = Date.now() + turnTimeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (sendOptions?.signal?.aborted === true && !aborted) {
|
||||||
|
aborted = true;
|
||||||
|
await client.abort();
|
||||||
|
}
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const next = queue.shift() as RuntimeEvent;
|
||||||
|
if (next.type === 'turn_end') {
|
||||||
|
yield { type: 'turn_end', reason: aborted ? 'cancelled' : next.reason };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield next;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
wake = resolve;
|
||||||
|
setTimeout(resolve, POLL_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
yield { type: 'error', kind: 'unknown_result', message: `Pi 回合超过 ${turnTimeoutMs}ms 未结束` };
|
||||||
|
yield { type: 'turn_end', reason: 'error' };
|
||||||
|
} catch (error) {
|
||||||
|
yield { type: 'error', kind: 'unknown_result', message: error instanceof Error ? error.message : 'Pi 子进程异常' };
|
||||||
|
yield { type: 'turn_end', reason: 'error' };
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolveToolResult(_callId: string, _receipt: ExecutionReceipt): void {
|
||||||
|
// 工具由 Pi 侧扩展执行(A3.11):回执要经扩展桥送回去,这里暂时无处可送
|
||||||
|
},
|
||||||
|
abort(): void {
|
||||||
|
void client.abort();
|
||||||
|
},
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
await client.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,8 @@ export async function runTurn(input: TurnRunnerInput): Promise<TurnResult> {
|
||||||
const { runtimeSession, sessionId, runId, assistantMessageId, publish, invoke, signal } = input;
|
const { runtimeSession, sessionId, runId, assistantMessageId, publish, invoke, signal } = input;
|
||||||
let emitted = '';
|
let emitted = '';
|
||||||
let toolCalls = 0;
|
let toolCalls = 0;
|
||||||
let finishReason: TurnResult['finishReason'] = 'stop';
|
let sawError = false;
|
||||||
|
let lastTurnEndReason: TurnResult['finishReason'] = 'stop';
|
||||||
|
|
||||||
publish({ sessionId, runId, type: 'run.state', data: { state: 'running' } });
|
publish({ sessionId, runId, type: 'run.state', data: { state: 'running' } });
|
||||||
|
|
||||||
|
|
@ -94,11 +95,11 @@ export async function runTurn(input: TurnRunnerInput): Promise<TurnResult> {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: { code: `engine.${event.kind}`, message: event.message, retryable: event.kind !== 'forbidden', errorKind: event.kind },
|
data: { code: `engine.${event.kind}`, message: event.message, retryable: event.kind !== 'forbidden', errorKind: event.kind },
|
||||||
});
|
});
|
||||||
finishReason = 'error';
|
sawError = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'turn_end': {
|
case 'turn_end': {
|
||||||
finishReason = event.reason;
|
lastTurnEndReason = event.reason;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -106,7 +107,7 @@ export async function runTurn(input: TurnRunnerInput): Promise<TurnResult> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
finishReason = 'error';
|
sawError = true;
|
||||||
publish({
|
publish({
|
||||||
sessionId,
|
sessionId,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -115,7 +116,8 @@ export async function runTurn(input: TurnRunnerInput): Promise<TurnResult> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal.aborted && finishReason === 'stop') finishReason = 'cancelled';
|
// 结束原因取「最严重的那个」:取消 > 错误 > 正常结束,避免被 turn_end 覆盖回 stop
|
||||||
|
const finishReason: TurnResult['finishReason'] = signal.aborted ? 'cancelled' : sawError ? 'error' : lastTurnEndReason;
|
||||||
publish({
|
publish({
|
||||||
sessionId,
|
sessionId,
|
||||||
runId,
|
runId,
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,14 @@ export interface LlmConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EngineConfig {
|
||||||
|
/** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */
|
||||||
|
kind: 'fake' | 'pi';
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
llm: LlmConfig;
|
llm: LlmConfig;
|
||||||
|
engine: EngineConfig;
|
||||||
database: { path: string };
|
database: { path: string };
|
||||||
server: { host: string; port: number };
|
server: { host: string; port: number };
|
||||||
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
|
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
|
||||||
|
|
@ -47,6 +53,7 @@ export function configFilePath(input: LoadConfigInput = {}): string {
|
||||||
|
|
||||||
interface FileConfig {
|
interface FileConfig {
|
||||||
llm?: Partial<LlmConfig>;
|
llm?: Partial<LlmConfig>;
|
||||||
|
engine?: Partial<EngineConfig>;
|
||||||
database?: { path?: string };
|
database?: { path?: string };
|
||||||
server?: { host?: string; port?: number };
|
server?: { host?: string; port?: number };
|
||||||
clientDist?: string;
|
clientDist?: string;
|
||||||
|
|
@ -76,8 +83,15 @@ export function loadConfig(input: LoadConfigInput = {}): PlatformConfig {
|
||||||
|
|
||||||
const port = Number.parseInt(env.APS_PORT ?? '', 10);
|
const port = Number.parseInt(env.APS_PORT ?? '', 10);
|
||||||
|
|
||||||
|
// 引擎选择:显式配置优先;没配就按「有没有密钥」自动决定——没密钥只能用假引擎(ADR-0011)
|
||||||
|
const requestedEngine = (env.APS_ENGINE ?? file.engine?.kind ?? '').toLowerCase();
|
||||||
|
const engine: EngineConfig = {
|
||||||
|
kind: requestedEngine === 'pi' || requestedEngine === 'fake' ? (requestedEngine as EngineConfig['kind']) : apiKey.trim() !== '' ? 'pi' : 'fake',
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
llm: { provider, baseUrl, model, apiKey },
|
llm: { provider, baseUrl, model, apiKey },
|
||||||
|
engine,
|
||||||
database: { path: dbPath },
|
database: { path: dbPath },
|
||||||
server: {
|
server: {
|
||||||
host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1',
|
host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1',
|
||||||
|
|
@ -96,6 +110,7 @@ export function isEngineConfigured(config: PlatformConfig): boolean {
|
||||||
export function redactConfig(config: PlatformConfig): Record<string, unknown> {
|
export function redactConfig(config: PlatformConfig): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
llm: {
|
llm: {
|
||||||
|
engine: { kind: config.engine.kind },
|
||||||
provider: config.llm.provider,
|
provider: config.llm.provider,
|
||||||
baseUrl: config.llm.baseUrl,
|
baseUrl: config.llm.baseUrl,
|
||||||
model: config.llm.model,
|
model: config.llm.model,
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,11 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const host = options.host ?? config.server.host;
|
const host = options.host ?? config.server.host;
|
||||||
const port = options.port ?? config.server.port;
|
const port = options.port ?? config.server.port;
|
||||||
|
const runtime = options.runtime ?? (await selectRuntime(config));
|
||||||
const database: Database = openDatabase(options.databasePath ?? config.database.path);
|
const database: Database = openDatabase(options.databasePath ?? config.database.path);
|
||||||
const service = new ApplicationService({
|
const service = new ApplicationService({
|
||||||
...(options.version === undefined ? {} : { version: options.version }),
|
...(options.version === undefined ? {} : { version: options.version }),
|
||||||
...(options.runtime === undefined ? {} : { runtime: options.runtime }),
|
runtime,
|
||||||
sessionRepository: new SessionRepository(database),
|
sessionRepository: new SessionRepository(database),
|
||||||
});
|
});
|
||||||
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
|
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
|
||||||
|
|
@ -79,3 +80,19 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** 引擎选择:显式注入 > 配置里的 engine.kind;选 pi 但没密钥时明确回退到假引擎 */
|
||||||
|
async function selectRuntime(config: ReturnType<typeof loadConfig>): Promise<AgentRuntime> {
|
||||||
|
if (config.engine.kind !== 'pi') {
|
||||||
|
const { createFakeRuntime } = await import('./agent/fake-runtime.js');
|
||||||
|
const { createRulePlanner, plannerToFakeTurn } = await import('./agent/rule-planner.js');
|
||||||
|
return createFakeRuntime({ name: 'fake', planner: plannerToFakeTurn(createRulePlanner()) });
|
||||||
|
}
|
||||||
|
if (config.llm.apiKey.trim() === '') {
|
||||||
|
console.error('警告:配置要求 Pi 引擎但没有模型密钥,已回退到假引擎(ADR-0011 允许未配置时降级)');
|
||||||
|
const { createFakeRuntime } = await import('./agent/fake-runtime.js');
|
||||||
|
const { createRulePlanner, plannerToFakeTurn } = await import('./agent/rule-planner.js');
|
||||||
|
return createFakeRuntime({ name: 'fake', planner: plannerToFakeTurn(createRulePlanner()) });
|
||||||
|
}
|
||||||
|
const { createPiRuntime } = await import('./agent/pi/runtime.js');
|
||||||
|
return createPiRuntime({ provider: config.llm.provider, model: config.llm.model, apiKey: config.llm.apiKey });
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,10 @@
|
||||||
"types": "./dist/agent/fake-runtime.d.ts",
|
"types": "./dist/agent/fake-runtime.d.ts",
|
||||||
"default": "./dist/agent/fake-runtime.js"
|
"default": "./dist/agent/fake-runtime.js"
|
||||||
},
|
},
|
||||||
|
"./agent/pi/runtime": {
|
||||||
|
"types": "./dist/agent/pi/runtime.d.ts",
|
||||||
|
"default": "./dist/agent/pi/runtime.js"
|
||||||
|
},
|
||||||
"./agent/conformance": {
|
"./agent/conformance": {
|
||||||
"types": "./dist/agent/conformance.d.ts",
|
"types": "./dist/agent/conformance.d.ts",
|
||||||
"default": "./dist/agent/conformance.js"
|
"default": "./dist/agent/conformance.js"
|
||||||
|
|
@ -40,6 +44,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aps/contracts": "workspace:*",
|
"@aps/contracts": "workspace:*",
|
||||||
"@aps/provider-sdk": "workspace:*"
|
"@aps/provider-sdk": "workspace:*",
|
||||||
|
"@earendil-works/pi-agent-core": "0.87.1",
|
||||||
|
"@earendil-works/pi-coding-agent": "0.87.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue