feat(agent): add controlled task execution

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent d018bcae67
commit b31033e9ce
48 changed files with 14498 additions and 86 deletions
+347 -2
View File
@@ -2,13 +2,20 @@ import { type Router } from "express";
import { z } from "zod";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { getLocalAgentContext } from "../context/localContext.js";
import { agentModelOptions } from "../chat/models.js";
import { config } from "../config.js";
import {
getLocalAgentContext,
getTrustedProductCredential,
} from "../context/localContext.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
type SessionMetadataStore,
type SessionRecord,
type SessionPurpose,
readPurpose,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
@@ -16,10 +23,30 @@ import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
import { buildForkedSessionUiState } from "./chatSession.js";
import { type StreamSubscriber } from "./chatUiState.js";
import { agentTriggerContextSchema } from "./agentTrigger.js";
import {
bindOperationalTaskCredential,
clearOperationalTaskLease,
operationalTaskClient,
releaseOperationalTaskCredential,
} from "../product/operational-task-client.js";
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
trigger_context: agentTriggerContextSchema.optional(),
});
const taskExecutionPayloadSchema = z.object({
task_id: z.string().uuid(),
run_id: z.string().uuid(),
execution_request_id: z.string().uuid(),
workflow: z.string().trim().min(1).max(120).optional(),
});
const sessionListQuerySchema = z.object({
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
task_id: z.string().uuid().optional(),
});
const forkPayloadSchema = z.object({
@@ -60,6 +87,87 @@ export const registerChatSessionRoutes = (
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
const startTaskExecution = (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
model: string,
workflow?: string,
) => {
queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
void fetch(new URL("/api/v1/agent/chat/stream", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
session_id: record.sessionId,
model,
message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,再按任务类型更新计划、证据和报告。`,
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
clearOperationalTaskLease(record.sessionId);
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
}
}).catch((error) => {
clearOperationalTaskLease(record.sessionId);
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
});
});
};
const prepareTaskExecution = async (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
input: z.infer<typeof taskExecutionPayloadSchema>,
) => {
bindOperationalTaskCredential(record.sessionId, credential);
try {
const taskContext = await operationalTaskClient.getAgentContext(record.sessionId, record.taskId!);
const task = taskContext.task && typeof taskContext.task === "object"
? taskContext.task as Record<string, unknown>
: null;
const activeRun = task?.active_run && typeof task.active_run === "object"
? task.active_run as Record<string, unknown>
: null;
if (
!activeRun
|| activeRun.run_id !== input.run_id
|| activeRun.execution_request_id !== input.execution_request_id
|| typeof activeRun.revision !== "number"
|| typeof activeRun.status !== "string"
) {
throw new Error("task context did not include the requested active run");
}
if (activeRun.status === "queued") {
await operationalTaskClient.claimTask(record.sessionId, record.taskId!, input.run_id, {
requestId: input.execution_request_id,
expectedRevision: activeRun.revision,
sourceSessionId: record.sessionId,
});
return "running";
}
if (
["claimed", "running"].includes(activeRun.status)
&& activeRun.agent_session_id === record.sessionId
) {
return "running";
}
throw new Error(`task run is not executable from this session (${activeRun.status})`);
} finally {
releaseOperationalTaskCredential(record.sessionId);
}
};
chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
@@ -75,6 +183,29 @@ export const registerChatSessionRoutes = (
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const trigger = parsed.data.trigger_context;
const purpose: SessionPurpose = trigger ? "task_execution" : "ordinary_chat";
const taskId = trigger?.subject_id;
if (trigger) {
const existingTriggeredSession = await sessionMetadataStore.findByTrigger(
{ actorKey, projectId, projectKey, userId },
trigger.trigger_id,
);
if (existingTriggeredSession) {
res.status(200).json({
session_id: existingTriggeredSession.sessionId,
created_at: existingTriggeredSession.createdAt,
updated_at: existingTriggeredSession.updatedAt,
status: existingTriggeredSession.status,
title: existingTriggeredSession.title,
parent_session_id: existingTriggeredSession.parentSessionId,
created: false,
event_duplicate: true,
trigger_id: trigger.trigger_id,
});
return;
}
}
const requestedSessionId = parsed.data.session_id?.trim();
let sessionId = requestedSessionId;
if (!sessionId) {
@@ -95,6 +226,16 @@ export const registerChatSessionRoutes = (
projectId,
projectKey,
sessionId,
purpose,
taskId,
trigger: trigger ? {
triggerId: trigger.trigger_id,
source: trigger.source,
subjectType: trigger.subject_type,
subjectId: trigger.subject_id,
workflow: trigger.workflow,
occurredAt: trigger.occurred_at,
} : undefined,
userId,
});
@@ -105,10 +246,194 @@ export const registerChatSessionRoutes = (
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
created,
event_duplicate: false,
trigger_id: record.triggerId,
purpose: readPurpose(record),
task_id: record.taskId,
});
});
chatRouter.post("/task/:task_id/collaboration/session", async (req, res) => {
const taskId = z.string().uuid().safeParse(req.params.task_id);
if (!taskId.success) {
res.status(400).json({ message: "valid task_id is required" });
return;
}
const authContext = getLocalAgentContext(req);
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task collaboration requires product credentials" });
return;
}
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByTask(
metadataContext,
taskId.data,
"task_collaboration",
);
const validationSessionId = existing?.sessionId ?? `task-access-${crypto.randomUUID()}`;
bindOperationalTaskCredential(validationSessionId, credential);
try {
await operationalTaskClient.getAgentContext(validationSessionId, taskId.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task access validation failed",
});
return;
} finally {
releaseOperationalTaskCredential(validationSessionId);
}
if (existing) {
res.json({
session_id: existing.sessionId,
purpose: readPurpose(existing),
task_id: existing.taskId,
created: false,
status: existing.status,
});
return;
}
const runtimeSession = await runtime.createSession();
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_collaboration",
taskId: taskId.data,
});
const titled = await sessionMetadataStore.touch(record, { title: "任务协作" });
res.status(201).json({
session_id: titled.sessionId,
purpose: readPurpose(titled),
task_id: titled.taskId,
created: true,
status: titled.status,
});
});
chatRouter.post("/task-executions", async (req, res) => {
const parsed = taskExecutionPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten() });
return;
}
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task execution requires product credentials" });
return;
}
const authContext = getLocalAgentContext(req);
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByRequest(metadataContext, parsed.data.execution_request_id);
if (existing) {
if (
existing.taskId !== parsed.data.task_id
|| existing.runId !== parsed.data.run_id
|| readPurpose(existing) !== "task_execution"
) {
res.status(409).json({ message: "request_id is already bound to another task" });
return;
}
if (activeRuns.get(existing.sessionId)?.status === "running") {
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: existing.modelId,
});
return;
}
const taskModel = existing.modelId;
if (!taskModel) {
res.status(409).json({ message: "task execution session has no model binding" });
return;
}
try {
await prepareTaskExecution(existing, credential, parsed.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 409).json({
message: error instanceof Error ? error.message : "task execution recovery failed",
});
return;
}
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: taskModel,
});
startTaskExecution(existing, credential, taskModel, parsed.data.workflow);
return;
}
let taskModel: string;
try {
taskModel = await runtime.resolveAvailableModel([
config.OPERATIONAL_TASK_MODEL ?? config.OPENCODE_MODEL,
...agentModelOptions.map((option) => option.id),
]);
} catch (error) {
res.status(503).json({
message: error instanceof Error ? error.message : "no operational task model is available",
});
return;
}
const runtimeSession = await runtime.createSession();
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_execution",
taskId: parsed.data.task_id,
runId: parsed.data.run_id,
requestId: parsed.data.execution_request_id,
modelId: taskModel,
});
try {
await prepareTaskExecution(record, credential, parsed.data);
} catch (error) {
await sessionMetadataStore.remove(record);
clearOperationalTaskLease(record.sessionId);
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task claim failed",
});
return;
}
res.status(202).json({
session_id: record.sessionId,
task_id: record.taskId,
request_id: record.requestId,
run_id: record.runId,
purpose: readPurpose(record),
created: true,
status: "running",
model: taskModel,
});
startTaskExecution(record, credential, taskModel, parsed.data.workflow);
});
chatRouter.get("/sessions", async (req, res) => {
const query = sessionListQuerySchema.safeParse(req.query);
if (!query.success) {
res.status(400).json({ message: "invalid session filter", detail: query.error.flatten() });
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
@@ -119,6 +444,9 @@ export const registerChatSessionRoutes = (
projectId,
projectKey,
userId,
}, {
purpose: query.data.purpose ?? "ordinary_chat",
taskId: query.data.task_id,
});
res.json({
sessions: records.map((record) => ({
@@ -130,6 +458,13 @@ export const registerChatSessionRoutes = (
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
purpose: readPurpose(record),
task_id: record.taskId,
trigger: record.triggerWorkflow === "water_quality_complaint" ? {
subject_id: record.triggerSubjectId,
workflow: record.triggerWorkflow,
occurred_at: record.triggerOccurredAt,
} : undefined,
})),
});
});
@@ -172,9 +507,12 @@ export const registerChatSessionRoutes = (
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
purpose: readPurpose(sessionRecord),
task_id: sessionRecord.taskId,
});
});
@@ -211,7 +549,10 @@ export const registerChatSessionRoutes = (
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [],
messages: readPurpose(sessionRecord) === "task_collaboration"
? state?.messages ?? []
: run?.messages ?? state?.messages ?? [],
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
}),
@@ -352,6 +693,10 @@ export const registerChatSessionRoutes = (
sourceSessionId,
)
: null;
if (sourceSessionRecord && readPurpose(sourceSessionRecord) !== "ordinary_chat") {
res.status(409).json({ message: "task sessions cannot be forked into ordinary chat history" });
return;
}
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,