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:
@@ -1,3 +1,4 @@
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
@@ -112,8 +113,10 @@ const isUiMessage = (value: unknown): value is UIMessage =>
|
||||
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
|
||||
Array.isArray(value.parts);
|
||||
|
||||
const readUiMessageText = (message: UIMessage | undefined) =>
|
||||
message?.parts
|
||||
const readUiMessageText = (message: UIMessage | undefined) => {
|
||||
const finalPart = message?.parts.slice().reverse().find((part) => isObjectRecord(part) && part.type === "data-final_answer" && isObjectRecord(part.data) && typeof part.data.content === "string");
|
||||
if (finalPart && "data" in finalPart && isObjectRecord(finalPart.data)) return String(finalPart.data.content).trim();
|
||||
return message?.parts
|
||||
.flatMap((part) =>
|
||||
isObjectRecord(part) && part.type === "text" && typeof part.text === "string"
|
||||
? [part.text]
|
||||
@@ -121,6 +124,7 @@ const readUiMessageText = (message: UIMessage | undefined) =>
|
||||
)
|
||||
.join("")
|
||||
.trim() ?? "";
|
||||
};
|
||||
|
||||
const readLatestUserText = (messages: UIMessage[]) => {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
@@ -142,10 +146,8 @@ const toUiMessages = (messages: unknown[] | undefined) =>
|
||||
export const toCollaborationMessages = (messages: UIMessage[]) =>
|
||||
messages.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
const textParts = message.parts.filter(
|
||||
(part) => isObjectRecord(part) && part.type === "text" && typeof part.text === "string",
|
||||
);
|
||||
return textParts.length > 0 ? [{ ...message, parts: textParts } as UIMessage] : [];
|
||||
const content = readUiMessageText(message);
|
||||
return content ? [{ ...message, parts: [{ type: "text" as const, text: content }] } as UIMessage] : [];
|
||||
});
|
||||
|
||||
const toLegacyMessages = (messages: UIMessage[]) =>
|
||||
@@ -651,6 +653,10 @@ export const registerChatStreamRoute = (
|
||||
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
||||
isError: false,
|
||||
}));
|
||||
} else if (event === "final_answer") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message, content: typeof data.content === "string" ? data.content : message.content,
|
||||
}));
|
||||
} else if (event === "progress") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
@@ -826,6 +832,17 @@ export const registerChatStreamRoute = (
|
||||
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
|
||||
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
|
||||
: undefined;
|
||||
let credentialFailure: Error | undefined;
|
||||
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(binding.sessionId, (event) => {
|
||||
publish(event.type, { ...event, session_id: clientSessionId, request_id: event.requestId });
|
||||
if (event.type === "credential_refresh_failed") {
|
||||
credentialFailure = new Error(event.message);
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
if (sessionPurpose === "task_execution") credentialRefreshCoordinator.enableBackground(binding.sessionId);
|
||||
const cancelCredentialRefresh = () => credentialRefreshCoordinator.cancelSession(binding.sessionId);
|
||||
abortController.signal.addEventListener("abort", cancelCredentialRefresh, { once: true });
|
||||
let taskHeartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let taskHeartbeatActive = true;
|
||||
let taskHeartbeatPromise: Promise<void> | undefined;
|
||||
@@ -897,7 +914,9 @@ export const registerChatStreamRoute = (
|
||||
publish(event, data);
|
||||
},
|
||||
});
|
||||
if (taskLeaseFailure) {
|
||||
if (credentialFailure) {
|
||||
publish("error", { session_id: clientSessionId, message: "会话凭据续期失败,本次执行已停止。" });
|
||||
} else if (taskLeaseFailure) {
|
||||
publish("error", {
|
||||
session_id: clientSessionId,
|
||||
message: "工况任务租约已失效,本次执行已停止,等待任务重新领取。",
|
||||
@@ -920,7 +939,7 @@ export const registerChatStreamRoute = (
|
||||
activeSessionRecord.taskId,
|
||||
activeSessionRecord.runId,
|
||||
clientSessionId,
|
||||
streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
|
||||
credentialFailure ? "CREDENTIAL_REFRESH_FAILED" : streamResult.aborted ? "AGENT_ABORTED" : "AGENT_STREAM_FAILED",
|
||||
streamResult.aborted ? "Agent execution was aborted" : "Agent stream failed",
|
||||
);
|
||||
taskRunFinalized = true;
|
||||
@@ -963,7 +982,7 @@ export const registerChatStreamRoute = (
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
|
||||
const latestSessionRecord =
|
||||
(await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
@@ -1046,6 +1065,8 @@ export const registerChatStreamRoute = (
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
unsubscribeCredentialRefresh();
|
||||
abortController.signal.removeEventListener("abort", cancelCredentialRefresh);
|
||||
await stopTaskHeartbeat();
|
||||
await taskArtifactPublishQueue.catch(() => undefined);
|
||||
unregisterFrontendActionSink?.();
|
||||
@@ -1079,7 +1100,7 @@ export const registerChatStreamRoute = (
|
||||
});
|
||||
sessionBridge.finalizeRequest(clientSessionId);
|
||||
releaseOperationalTaskCredential(binding.sessionId);
|
||||
activeRun.status = taskLeaseFailure
|
||||
activeRun.status = (taskLeaseFailure || credentialFailure)
|
||||
? "error"
|
||||
: abortController.signal.aborted
|
||||
? "aborted"
|
||||
|
||||
Reference in New Issue
Block a user