feat(agent): migrate OpenCode 2 runtime and sandbox tools
Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
+33
-12
@@ -1,4 +1,6 @@
|
||||
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||
import { canAutomaticallyApprove } from "./chatPermissionPolicy.js";
|
||||
import { createBusinessStageTracker } from "./chatBusinessStages.js";
|
||||
|
||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||
import { type SupportedModel } from "../chat/models.js";
|
||||
@@ -187,6 +189,8 @@ export const streamPromptResponse = async ({
|
||||
let promptSettled = false;
|
||||
let aborted = signal?.aborted ?? false;
|
||||
let failed = false;
|
||||
let finalAnswer: string | undefined;
|
||||
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
|
||||
const debugContext = {
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
@@ -291,6 +295,7 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
});
|
||||
|
||||
let pendingEvent: Promise<{ type: "event"; result: IteratorResult<OpencodeEvent> }> | null = null;
|
||||
try {
|
||||
while (!done) {
|
||||
if (signal?.aborted) {
|
||||
@@ -302,7 +307,9 @@ export const streamPromptResponse = async ({
|
||||
break;
|
||||
}
|
||||
|
||||
const nextEvent = iterator
|
||||
// V2 prompt() acknowledges inbox admission before generation. Keep the same
|
||||
// pending read if that acknowledgement wins the race, so no event is consumed unseen.
|
||||
const nextEvent = pendingEvent ??= iterator
|
||||
.next()
|
||||
.then((result) => ({ type: "event" as const, result }));
|
||||
const nextPrompt = promptSettled
|
||||
@@ -329,6 +336,7 @@ export const streamPromptResponse = async ({
|
||||
if (next.type === "prompt") {
|
||||
continue;
|
||||
}
|
||||
pendingEvent = null;
|
||||
if (next.result.done) {
|
||||
break;
|
||||
}
|
||||
@@ -402,17 +410,18 @@ export const streamPromptResponse = async ({
|
||||
patterns: event.properties.patterns,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
status: approvalMode === "always" ? "completed" : "running",
|
||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
||||
status: autoApprove ? "completed" : "running",
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
approvalMode === "always"
|
||||
autoApprove
|
||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
||||
: buildPermissionDetail(event),
|
||||
});
|
||||
if (approvalMode === "always") {
|
||||
if (autoApprove) {
|
||||
await runtime.replyPermission({
|
||||
requestId: event.properties.id,
|
||||
sessionId,
|
||||
@@ -447,17 +456,18 @@ export const streamPromptResponse = async ({
|
||||
resources: event.properties.resources,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
status: approvalMode === "always" ? "completed" : "running",
|
||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
||||
status: autoApprove ? "completed" : "running",
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
approvalMode === "always"
|
||||
autoApprove
|
||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
||||
: buildPermissionV2Detail(event),
|
||||
});
|
||||
if (approvalMode === "always") {
|
||||
if (autoApprove) {
|
||||
await runtime.replyPermission({
|
||||
requestId: event.properties.id,
|
||||
sessionId,
|
||||
@@ -725,6 +735,16 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
if (part.tool === "final_answer") {
|
||||
if (part.state.status === "completed") {
|
||||
const output = parseToolOutput(part.state.output);
|
||||
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
|
||||
finalAnswer = (output as { answer: string }).answer;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
trackBusinessStage(part);
|
||||
if (part.tool === "publish_execution_plan" && part.state.status === "completed" && !emittedToolParts.has(part.id)) {
|
||||
const plan = executionPlanSchema.safeParse(part.state.input);
|
||||
const output = parseToolOutput(part.state.output);
|
||||
@@ -970,7 +990,7 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
|
||||
await promptPromise;
|
||||
if (!emittedText) {
|
||||
if (!emittedText && !finalAnswer) {
|
||||
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
@@ -978,6 +998,7 @@ export const streamPromptResponse = async ({
|
||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
|
||||
}
|
||||
await tokenSmoother.flush();
|
||||
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer });
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
@@ -990,7 +1011,7 @@ export const streamPromptResponse = async ({
|
||||
phase: "complete",
|
||||
status: "completed",
|
||||
title: "分析完成",
|
||||
detail: emittedText
|
||||
detail: (emittedText || finalAnswer)
|
||||
? "最终回答已生成并推送到前端。"
|
||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||
});
|
||||
@@ -1007,7 +1028,7 @@ export const streamPromptResponse = async ({
|
||||
return { aborted: false, failed: false, toolCallCount };
|
||||
} finally {
|
||||
await tokenSmoother.flush();
|
||||
await iterator.return?.(undefined);
|
||||
await iterator.return?.();
|
||||
if (!promptSettled && !aborted) {
|
||||
await promptPromise.catch(() => undefined);
|
||||
} else if (!promptSettled) {
|
||||
|
||||
Reference in New Issue
Block a user