feat(agent): add controlled task execution
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user