feat(tasks): enforce dynamic execution plans

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent 485ceff7e3
commit e0d37f9da9
13 changed files with 1942 additions and 17 deletions
+3
View File
@@ -29,6 +29,7 @@ Agent 负责:
| 发布事件调查计划及节点进度 | `publish_execution_plan` | | 发布事件调查计划及节点进度 | `publish_execution_plan` |
| 读取工况任务、证据和授权数据 | `get_operational_task_context` | | 读取工况任务、证据和授权数据 | `get_operational_task_context` |
| 查询任务授权范围内的实时或外围数据 | `query_operational_task_data` | | 查询任务授权范围内的实时或外围数据 | `query_operational_task_data` |
| 请求任务级数据访问并等待人工审核 | `request_task_data_access` |
| 发布精简调查报告和关键证据 | `publish_task_report` | | 发布精简调查报告和关键证据 | `publish_task_report` |
| 提议需人工审批的工单或命令 | `propose_task_action` | | 提议需人工审批的工单或命令 | `propose_task_action` |
| 记录用户明确提供的任务证据 | `add_task_human_evidence` | | 记录用户明确提供的任务证据 | `add_task_human_evidence` |
@@ -44,6 +45,8 @@ Agent 负责:
## 执行约束 ## 执行约束
事件调查由当前投诉工单或人工请求动态规划,不套用固定流程。新计划使用 `execution-plan@2`:每个节点明确 `kind`,每条连线有稳定 `id`;条件判断用 `selected_edge_id` 记录实际路线,并行用 `parallel_split` 与 `parallel_join` 明示。数据授权或工单审核用 `gate` 节点引用真实 `gate_ref`。如需再次排查或重拟草稿,新增节点并用 `revisits_node_id` 指向上次节点,保留旧节点与证据,不创建环形连线。未获得数据权限时先调用 `request_task_data_access`,发布等待节点并停止越权查询;拒绝后标记证据缺口。只有任务上下文包含批准的来源时才能查询。工单草稿可由人工编辑后确认下发,Agent 不得自行代替人工确认。
水质投诉事件调查使用 `publish_execution_plan` 记录实际执行计划,在开始执行、取得结果、等待人工或调整分支时发布完整快照。保留稳定节点 ID 和已完成证据,取消或不适用的分支显式标记。运行中或已完成节点的所有前置节点必须已完成或已跳过。完成节点必须提供真实来源;预设场景输入明确标为合成证据,未实际调用的查询不得写成已执行。工具返回失败表示后端未保存计划,必须按错误修正后重试,不能把失败调用当作已发布或直接结束本轮。现场采样、清洗、复测及回访保持等待人工确认,不能因本轮回复结束而完成。 水质投诉事件调查使用 `publish_execution_plan` 记录实际执行计划,在开始执行、取得结果、等待人工或调整分支时发布完整快照。保留稳定节点 ID 和已完成证据,取消或不适用的分支显式标记。运行中或已完成节点的所有前置节点必须已完成或已跳过。完成节点必须提供真实来源;预设场景输入明确标为合成证据,未实际调用的查询不得写成已执行。工具返回失败表示后端未保存计划,必须按错误修正后重试,不能把失败调用当作已发布或直接结束本轮。现场采样、清洗、复测及回访保持等待人工确认,不能因本轮回复结束而完成。
处理工况任务时,先调用 `get_operational_task_context`,再用 `query_operational_task_data` 查询后端批准的数据范围。`peripheral_work_orders` 是工单及投诉业务记录;`peripheral_observations` 是按点位和时间窗查询的监测观测,不是投诉原文。只把工具实际返回的数据写成系统证据,并直接引用返回的 `evidence.evidence_id`,不得用 `publish_task_report` 重写系统、外部或人工证据;该工具新增证据只允许明确的合成输入。`origin=synthetic` 必须标为合成证据,`truncated=true` 时不能据此断言完整范围没有异常。数据源缺失或不可用时保持相关节点 waiting 并记录缺口。计划可在调查中多次更新,纯计划发布是中间进度,不会结束未失败的任务。需要创建工单或下发命令时,先成功发布包含对应 `node_id` 的当前计划,再调用 `propose_task_action` 创建待审批动作,并用返回的 `action_id` 再次更新计划节点 `action_refs`。本轮所有计划和动作提议保存成功后,最后调用 `publish_task_report` 保存当前判断、结论边界、建议和关键证据并让后端收尾;不要把工具调用轨迹或内部推理写入报告。后端只用当前执行代、当前会话的计划和报告判断继续运行、等待或完成状态。只有后端上下文显示动作具备成功回执时,才能把关联人工节点标为完成。审批、执行与回执均由 TJWater Server 控制。 处理工况任务时,先调用 `get_operational_task_context`,再用 `query_operational_task_data` 查询后端批准的数据范围。`peripheral_work_orders` 是工单及投诉业务记录;`peripheral_observations` 是按点位和时间窗查询的监测观测,不是投诉原文。只把工具实际返回的数据写成系统证据,并直接引用返回的 `evidence.evidence_id`,不得用 `publish_task_report` 重写系统、外部或人工证据;该工具新增证据只允许明确的合成输入。`origin=synthetic` 必须标为合成证据,`truncated=true` 时不能据此断言完整范围没有异常。数据源缺失或不可用时保持相关节点 waiting 并记录缺口。计划可在调查中多次更新,纯计划发布是中间进度,不会结束未失败的任务。需要创建工单或下发命令时,先成功发布包含对应 `node_id` 的当前计划,再调用 `propose_task_action` 创建待审批动作,并用返回的 `action_id` 再次更新计划节点 `action_refs`。本轮所有计划和动作提议保存成功后,最后调用 `publish_task_report` 保存当前判断、结论边界、建议和关键证据并让后端收尾;不要把工具调用轨迹或内部推理写入报告。后端只用当前执行代、当前会话的计划和报告判断继续运行、等待或完成状态。只有后端上下文显示动作具备成功回执时,才能把关联人工节点标为完成。审批、执行与回执均由 TJWater Server 控制。
+8 -3
View File
@@ -1,11 +1,12 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
import { executionPlanSchema } from "../../src/routes/executionPlan.js"; import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js";
export default tool({ export default tool({
description: "Publish the current full execution plan and progress to TJWater Server. Plans may be updated multiple times during investigation; a plan-only publish is intermediate and does not finish the task unless it explicitly records Agent failure. Publish the relevant plan node before proposing an action, then republish the plan with the returned action_id before the final report. Preserve stable node IDs and completed evidence. A running or completed node may only depend on completed or skipped predecessors. Edges are dependencies or labeled conditional branches, never guessed from list order. Human actions stay waiting until externally confirmed. Evidence must identify synthetic inputs, tool results or human confirmation accurately. This records a plan, it does not execute actions.", description: "Publish the current full execution plan and progress to TJWater Server. Plans may be updated multiple times during investigation; a plan-only publish is intermediate and does not finish the task unless it explicitly records Agent failure. Publish the relevant plan node before proposing an action, then republish the plan with the returned action_id before the final report. Preserve stable node IDs and completed evidence. A running or completed node may only depend on completed or skipped predecessors. Edges are dependencies or labeled conditional branches, never guessed from list order. Human actions stay waiting until externally confirmed. Evidence must identify synthetic inputs, tool results or human confirmation accurately. This records a plan, it does not execute actions.",
args: { args: {
request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"), request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"),
schema: tool.schema.enum(["execution-plan@2"]),
title: tool.schema.string(), title: tool.schema.string(),
reason: tool.schema.string().describe("Why the plan or node status changed"), reason: tool.schema.string().describe("Why the plan or node status changed"),
nodes: tool.schema.array(tool.schema.object({ nodes: tool.schema.array(tool.schema.object({
@@ -14,12 +15,16 @@ export default tool({
owner: tool.schema.enum(["agent", "human"]), owner: tool.schema.enum(["agent", "human"]),
detail: tool.schema.string(), evidence: tool.schema.array(tool.schema.string()), detail: tool.schema.string(), evidence: tool.schema.array(tool.schema.string()),
action_refs: tool.schema.array(tool.schema.string()).optional(), action_refs: tool.schema.array(tool.schema.string()).optional(),
kind: tool.schema.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).optional(),
selected_edge_id: tool.schema.string().nullable().optional(),
gate_ref: tool.schema.string().nullable().optional(),
revisits_node_id: tool.schema.string().nullable().optional(),
})), })),
edges: tool.schema.array(tool.schema.object({ from: tool.schema.string(), to: tool.schema.string(), label: tool.schema.string() })), edges: tool.schema.array(tool.schema.object({ id: tool.schema.string().optional(), from: tool.schema.string(), to: tool.schema.string(), label: tool.schema.string() })),
}, },
async execute(args, context) { async execute(args, context) {
const { request_id, ...input } = args; const { request_id, ...input } = args;
const plan = executionPlanSchema.parse(input); const plan = newExecutionPlanSchema.parse(input);
const { baseUrl, internalToken } = getInternalBridgeConfig(); const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/operational-task/artifacts`, { const response = await fetch(`${baseUrl}/internal/operational-task/artifacts`, {
method: "POST", method: "POST",
@@ -0,0 +1,28 @@
import { tool } from "@opencode-ai/plugin";
import { getInternalBridgeConfig } from "../internalBridge.js";
export default tool({
description: "Request task-scoped access to one or more finite investigation data sources. This only creates a pending human review record. Do not query the requested sources until the approved scope appears in task context; after denial, mark the evidence gap and continue with available facts.",
args: {
request_id: tool.schema.string().uuid(),
node_id: tool.schema.string().min(1),
scopes: tool.schema.array(tool.schema.enum([
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
])).min(1),
reason: tool.schema.string().min(1),
},
async execute(args, context) {
const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/operational-task/access-requests`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ session_id: context.sessionID, request: {
requestId: args.request_id, nodeId: args.node_id, scopes: args.scopes, reason: args.reason,
} }),
});
const text = await response.text();
if (!response.ok) throw new Error(`task data access request failed (${response.status}): ${text}`);
return text;
},
});
+1113 -1
View File
File diff suppressed because it is too large Load Diff
+649 -2
View File
@@ -2327,6 +2327,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/operational-tasks/{task_id}/access-requests": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Request Operational Task Access */
post: operations["post_operational_tasks_task_id_access_requests"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Decide Operational Task Access */
post: operations["post_operational_tasks_task_id_access_requests_access_request_id_decision"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operational-tasks/{task_id}/actions": { "/api/v1/operational-tasks/{task_id}/actions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2344,6 +2378,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Confirm Operational Work Order Dispatch */
post: operations["post_operational_tasks_task_id_actions_action_id_confirm_dispatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operational-tasks/{task_id}/actions/{action_id}/decision": { "/api/v1/operational-tasks/{task_id}/actions/{action_id}/decision": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2361,6 +2412,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Update Operational Work Order Draft */
patch: operations["patch_operational_tasks_task_id_actions_action_id_draft"];
trace?: never;
};
"/api/v1/operational-tasks/{task_id}/actions/{action_id}/execute": { "/api/v1/operational-tasks/{task_id}/actions/{action_id}/execute": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2616,6 +2684,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/operational-tasks/{task_id}/withdraw-external-source": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Withdraw Operational Task External Source */
post: operations["post_operational_tasks_task_id_withdraw_external_source"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/patterns": { "/api/v1/patterns": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5983,6 +6068,8 @@ export interface components {
condition?: string | null; condition?: string | null;
/** From */ /** From */
from: string; from: string;
/** Id */
id?: string | null;
/** To */ /** To */
to: string; to: string;
}; };
@@ -5996,9 +6083,9 @@ export interface components {
reason: string; reason: string;
/** /**
* Schema * Schema
* @constant * @enum {string}
*/ */
schema: "execution-plan@1"; schema: "execution-plan@1" | "execution-plan@2";
/** Title */ /** Title */
title: string; title: string;
}; };
@@ -6010,13 +6097,21 @@ export interface components {
detail: string; detail: string;
/** Evidence Refs */ /** Evidence Refs */
evidence_refs?: string[]; evidence_refs?: string[];
/** Gate Ref */
gate_ref?: string | null;
/** Id */ /** Id */
id: string; id: string;
/** Kind */
kind?: ("activity" | "decision" | "parallel_split" | "parallel_join" | "gate") | null;
/** /**
* Owner * Owner
* @enum {string} * @enum {string}
*/ */
owner: "agent" | "human"; owner: "agent" | "human";
/** Revisits Node Id */
revisits_node_id?: string | null;
/** Selected Edge Id */
selected_edge_id?: string | null;
/** /**
* Status * Status
* @enum {string} * @enum {string}
@@ -6103,6 +6198,16 @@ export interface components {
source_revision: number; source_revision: number;
task: components["schemas"]["OperationalTaskDetail"]; task: components["schemas"]["OperationalTaskDetail"];
}; };
/** ExternalSourceWithdrawalCommand */
ExternalSourceWithdrawalCommand: {
/**
* Request Id
* Format: uuid
*/
request_id: string;
/** Source Event Id */
source_event_id: string;
};
/** ExternalWorkOrderSnapshot */ /** ExternalWorkOrderSnapshot */
ExternalWorkOrderSnapshot: { ExternalWorkOrderSnapshot: {
/** Activity */ /** Activity */
@@ -6230,6 +6335,10 @@ export interface components {
decided_by?: string | null; decided_by?: string | null;
/** Decision Comment */ /** Decision Comment */
decision_comment?: string | null; decision_comment?: string | null;
/** Edited At */
edited_at?: string | null;
/** Edited By */
edited_by?: string | null;
/** Evidence Refs */ /** Evidence Refs */
evidence_refs: string[]; evidence_refs: string[];
/** Executed At */ /** Executed At */
@@ -6552,6 +6661,8 @@ export interface components {
}; };
/** OperationalTaskDetail */ /** OperationalTaskDetail */
OperationalTaskDetail: { OperationalTaskDetail: {
/** Access Requests */
access_requests?: components["schemas"]["TaskAccessRequest"][];
/** Actions */ /** Actions */
actions: components["schemas"]["HumanApprovalAction"][]; actions: components["schemas"]["HumanApprovalAction"][];
active_run?: components["schemas"]["OperationalTaskRun"] | null; active_run?: components["schemas"]["OperationalTaskRun"] | null;
@@ -6644,6 +6755,12 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
kind: "task_created" | "action_pending" | "report_updated" | "task_completed" | "task_failed" | "run_delivery_degraded" | "access_approval_pending" | "action_approval_pending" | "human_input_required" | "external_coordination_failed"; kind: "task_created" | "action_pending" | "report_updated" | "task_completed" | "task_failed" | "run_delivery_degraded" | "access_approval_pending" | "action_approval_pending" | "human_input_required" | "external_coordination_failed";
/**
* Lifecycle Status
* @default active
* @enum {string}
*/
lifecycle_status: "active" | "resolved";
/** /**
* Notification Id * Notification Id
* Format: uuid * Format: uuid
@@ -6655,11 +6772,23 @@ export interface components {
read_by?: string | null; read_by?: string | null;
/** Required Permission */ /** Required Permission */
required_permission?: string | null; required_permission?: string | null;
/** Resolution Reason */
resolution_reason?: string | null;
/** Resolved At */
resolved_at?: string | null;
/** /**
* Status * Status
* @enum {string} * @enum {string}
*/ */
status: "unread" | "read"; status: "unread" | "read";
/** Subject Id */
subject_id?: string | null;
/**
* Subject Type
* @default task
* @enum {string}
*/
subject_type: "task" | "run" | "access_request" | "action";
/** /**
* Task Id * Task Id
* Format: uuid * Format: uuid
@@ -6694,6 +6823,11 @@ export interface components {
}; };
/** OperationalTaskPermissions */ /** OperationalTaskPermissions */
OperationalTaskPermissions: { OperationalTaskPermissions: {
/**
* Can Decide Access
* @default false
*/
can_decide_access: boolean;
/** Can Decide Commands */ /** Can Decide Commands */
can_decide_commands: boolean; can_decide_commands: boolean;
/** Can Decide Work Orders */ /** Can Decide Work Orders */
@@ -7536,6 +7670,82 @@ export interface components {
/** Source Fid */ /** Source Fid */
source_fid: number; source_fid: number;
}; };
/** TaskAccessRequest */
TaskAccessRequest: {
/**
* Access Request Id
* Format: uuid
*/
access_request_id: string;
/** Approved Scopes */
approved_scopes: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data")[];
/** Comment */
comment?: string | null;
/** Decided At */
decided_at?: string | null;
/** Decided By */
decided_by?: string | null;
/** Node Id */
node_id: string;
/** Reason */
reason: string;
/**
* Requested At
* Format: date-time
*/
requested_at: string;
/** Requested By */
requested_by: string;
/** Scopes */
scopes: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data")[];
/**
* Status
* @enum {string}
*/
status: "pending" | "approved" | "rejected";
/**
* Task Id
* Format: uuid
*/
task_id: string;
/** Version */
version: number;
};
/** TaskAccessRequestCreate */
TaskAccessRequestCreate: {
/** Node Id */
node_id: string;
/** Reason */
reason: string;
/**
* Request Id
* Format: uuid
*/
request_id: string;
/** Scopes */
scopes: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data")[];
/** Source Session Id */
source_session_id: string;
};
/** TaskAccessRequestDecision */
TaskAccessRequestDecision: {
/** Approved Scopes */
approved_scopes?: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data")[];
/** Comment */
comment?: string | null;
/**
* Decision
* @enum {string}
*/
decision: "approved" | "rejected";
/** Expected Version */
expected_version: number;
/**
* Request Id
* Format: uuid
*/
request_id: string;
};
/** TaskAlertCommand */ /** TaskAlertCommand */
TaskAlertCommand: { TaskAlertCommand: {
/** Alert Id */ /** Alert Id */
@@ -7650,6 +7860,8 @@ export interface components {
}; };
/** TaskSource */ /** TaskSource */
TaskSource: { TaskSource: {
/** Deleted At */
deleted_at?: string | null;
/** Event Kind */ /** Event Kind */
event_kind?: string | null; event_kind?: string | null;
/** External Id */ /** External Id */
@@ -7742,6 +7954,27 @@ export interface components {
/** Title */ /** Title */
title: string; title: string;
}; };
/** WorkOrderConfirmDispatchCommand */
WorkOrderConfirmDispatchCommand: {
/** Expected Version */
expected_version: number;
/**
* Request Id
* Format: uuid
*/
request_id: string;
};
/** WorkOrderDraftUpdateCommand */
WorkOrderDraftUpdateCommand: {
/** Expected Version */
expected_version: number;
payload: components["schemas"]["CreateWorkOrderPayload"];
/**
* Request Id
* Format: uuid
*/
request_id: string;
};
/** WorkbenchAlertSource */ /** WorkbenchAlertSource */
WorkbenchAlertSource: { WorkbenchAlertSource: {
/** Run Id */ /** Run Id */
@@ -19772,6 +20005,171 @@ export interface operations {
}; };
}; };
}; };
post_operational_tasks_task_id_access_requests: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path: {
task_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TaskAccessRequestCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskAccessRequest"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_operational_tasks_task_id_access_requests_access_request_id_decision: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path: {
task_id: string;
access_request_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TaskAccessRequestDecision"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskAccessRequest"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_operational_tasks_task_id_actions: { post_operational_tasks_task_id_actions: {
parameters: { parameters: {
query?: never; query?: never;
@@ -19854,6 +20252,90 @@ export interface operations {
}; };
}; };
}; };
post_operational_tasks_task_id_actions_action_id_confirm_dispatch: {
parameters: {
query?: never;
header: {
Authorization?: string | null;
"X-Project-Id": string;
};
path: {
task_id: string;
action_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["WorkOrderConfirmDispatchCommand"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HumanApprovalAction"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_operational_tasks_task_id_actions_action_id_decision: { post_operational_tasks_task_id_actions_action_id_decision: {
parameters: { parameters: {
query?: never; query?: never;
@@ -19937,6 +20419,89 @@ export interface operations {
}; };
}; };
}; };
patch_operational_tasks_task_id_actions_action_id_draft: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path: {
task_id: string;
action_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["WorkOrderDraftUpdateCommand"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HumanApprovalAction"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_operational_tasks_task_id_actions_action_id_execute: { post_operational_tasks_task_id_actions_action_id_execute: {
parameters: { parameters: {
query?: never; query?: never;
@@ -21171,6 +21736,88 @@ export interface operations {
}; };
}; };
}; };
post_operational_tasks_task_id_withdraw_external_source: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path: {
task_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExternalSourceWithdrawalCommand"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OperationalTaskDetail"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
get_patterns: { get_patterns: {
parameters: { parameters: {
query?: { query?: {
+26 -1
View File
@@ -151,6 +151,12 @@ export const humanTaskEvidenceCommandSchema = z.object({
export type TaskEvidence = z.infer<typeof taskEvidenceSchema>; export type TaskEvidence = z.infer<typeof taskEvidenceSchema>;
export type TaskReport = z.infer<typeof taskReportSchema>; export type TaskReport = z.infer<typeof taskReportSchema>;
export type OperationalTaskDataQuery = z.infer<typeof operationalTaskDataQuerySchema>; export type OperationalTaskDataQuery = z.infer<typeof operationalTaskDataQuerySchema>;
export const expressAccessRequestSchema = z.object({
requestId: z.string().uuid(),
nodeId: z.string().min(1),
scopes: z.array(taskDataSourceSchema).min(1).max(7),
reason: z.string().min(1),
});
export type HumanTaskEvidenceCommand = z.infer<typeof humanTaskEvidenceCommandSchema>; export type HumanTaskEvidenceCommand = z.infer<typeof humanTaskEvidenceCommandSchema>;
export type OperationalTaskContext = Record<string, unknown>; export type OperationalTaskContext = Record<string, unknown>;
@@ -354,6 +360,18 @@ export class OperationalTaskClient {
return { evidence, snapshot }; return { evidence, snapshot };
} }
async requestDataAccess(runtimeSessionId: string, taskId: string, input: {
requestId: string; sourceSessionId: string; nodeId: string; scopes: OperationalTaskDataQuery["source"][]; reason: string;
}) {
return this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/access-requests`, {
method: "POST",
body: JSON.stringify({
request_id: input.requestId, source_session_id: input.sourceSessionId,
node_id: input.nodeId, scopes: input.scopes, reason: input.reason,
}),
});
}
async proposeAction(runtimeSessionId: string, taskId: string, input: ProposedTaskAction) { async proposeAction(runtimeSessionId: string, taskId: string, input: ProposedTaskAction) {
const result = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/actions`, { const result = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/actions`, {
method: "POST", method: "POST",
@@ -475,7 +493,7 @@ const toServerPlan = (input: Record<string, unknown>) => {
const nodes = Array.isArray(input.nodes) ? input.nodes : []; const nodes = Array.isArray(input.nodes) ? input.nodes : [];
const edges = Array.isArray(input.edges) ? input.edges : []; const edges = Array.isArray(input.edges) ? input.edges : [];
return { return {
schema: "execution-plan@1", schema: input.schema === "execution-plan@2" ? "execution-plan@2" : "execution-plan@1",
title: input.title, title: input.title,
reason: input.reason, reason: input.reason,
nodes: nodes.map((value) => { nodes: nodes.map((value) => {
@@ -488,6 +506,12 @@ const toServerPlan = (input: Record<string, unknown>) => {
detail: node.detail, detail: node.detail,
evidence_refs: Array.isArray(node.evidence) ? node.evidence : [], evidence_refs: Array.isArray(node.evidence) ? node.evidence : [],
action_refs: Array.isArray(node.action_refs) ? node.action_refs : [], action_refs: Array.isArray(node.action_refs) ? node.action_refs : [],
...(input.schema === "execution-plan@2" ? {
kind: node.kind,
selected_edge_id: node.selected_edge_id ?? null,
gate_ref: node.gate_ref ?? null,
revisits_node_id: node.revisits_node_id ?? null,
} : {}),
}; };
}), }),
edges: edges.map((value) => { edges: edges.map((value) => {
@@ -496,6 +520,7 @@ const toServerPlan = (input: Record<string, unknown>) => {
from: edge.from, from: edge.from,
to: edge.to, to: edge.to,
...(typeof edge.label === "string" && edge.label.trim() ? { condition: edge.label } : {}), ...(typeof edge.label === "string" && edge.label.trim() ? { condition: edge.label } : {}),
...(input.schema === "execution-plan@2" ? { id: edge.id } : {}),
}; };
}), }),
}; };
+2 -2
View File
@@ -33,8 +33,8 @@ export const buildAgentTriggerPrompt = (trigger: AgentTriggerContext) => [
event_id: trigger.trigger_id, event_id: trigger.trigger_id,
})}</external_work_order>`, })}</external_work_order>`,
"这是合成演练数据,不得表述为生产事实。", "这是合成演练数据,不得表述为生产事实。",
"开始调查前调用 publish_execution_plan 发布本次调查的结构化计划,明确并行依赖和条件分支;执行前、取得结果后、遇到阻塞或修改计划时再次发布完整快照。保留节点 ID、已完成节点及证据,重试用新节点,不要按时间推算进度。", "根据这张工单的事实动态规划本次调查,不套用预设路线。开始调查前调用 publish_execution_plan 发布 execution-plan@2,明确路线选择、并行分叉与汇合、人工审核点;执行前、取得结果后、遇到阻塞或修改计划时再次发布完整快照。保留节点 ID、已完成节点及证据,重试用新节点,不要按时间推算进度。",
"每个完成节点必须列明实际依据。预设证据标注为合成场景输入,不得声称执行过未调用的数据查询工具。现场采样、清洗、复测、回访等人工工作保持 waiting,写清等待内容,不得因回复结束标记完成。", "每个完成节点必须列明实际依据。预设证据标注为合成场景输入,不得声称执行过未调用的数据查询工具。现场采样、清洗、复测、回访等人工工作保持 waiting,写清等待内容,不得因回复结束标记完成。",
"外围样板随事件提供的合成场景输入(不是 Agent 实时查询结果):上游三个模拟水质监测点的数据质量均为 valid,演示指标均在场景阈值内,且相邻片区没有同类投诉;投诉集中在演示小区,二次供水水箱最近一次清洗记录待核验。", "外围样板随事件提供的合成场景输入(不是 Agent 实时查询结果):上游三个模拟水质监测点的数据质量均为 valid,演示指标均在场景阈值内,且相邻片区没有同类投诉;投诉集中在演示小区,二次供水水箱最近一次清洗记录待核验。",
"请按‘上游数据质量正常、上游水质未见异常 → 疑似二次供水环节问题(待现场核验)’形成阶段性判断,并给出处置、复测和回访建议。不得将疑似判断写成已确认事故。", "依据实际取得的证据判断可能原因,区分合成场景输入、已查询数据和待现场核验事项;给出处置、复测和回访建议。不得将疑似判断写成已确认事故。",
].join("\n"); ].join("\n");
+1 -1
View File
@@ -105,7 +105,7 @@ export const registerChatSessionRoutes = (
body: JSON.stringify({ body: JSON.stringify({
session_id: record.sessionId, session_id: record.sessionId,
model, model,
message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,再按任务类型更新计划、证据和报告。`, message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,依据当前工单或人工请求动态规划 execution-plan@2;按实际选择的路线更新计划、证据和报告,数据权限不足时先申请并等待审核。`,
}), }),
}).then(async (response) => { }).then(async (response) => {
const reader = response.body?.getReader(); const reader = response.body?.getReader();
+52 -3
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/); const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
export const executionPlanSchema = z.object({ export const executionPlanSchema = z.object({
schema: z.enum(["execution-plan@1", "execution-plan@2"]).default("execution-plan@1"),
title: z.string().trim().min(1).max(120), title: z.string().trim().min(1).max(120),
reason: z.string().trim().min(1).max(1000), reason: z.string().trim().min(1).max(1000),
nodes: z.array(z.object({ nodes: z.array(z.object({
@@ -12,18 +13,27 @@ export const executionPlanSchema = z.object({
detail: z.string().max(2000), detail: z.string().max(2000),
evidence: z.array(z.string().min(1).max(1000)).max(12), evidence: z.array(z.string().min(1).max(1000)).max(12),
action_refs: z.array(z.string().uuid()).max(12).default([]), action_refs: z.array(z.string().uuid()).max(12).default([]),
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).optional(),
selected_edge_id: id.nullable().optional(),
gate_ref: z.string().uuid().nullable().optional(),
revisits_node_id: id.nullable().optional(),
})).min(1).max(40), })).min(1).max(40),
edges: z.array(z.object({ from: id, to: id, label: z.string().max(120) })).max(80), edges: z.array(z.object({ id: id.optional(), from: id, to: id, label: z.string().max(120) })).max(80),
}).superRefine((plan, context) => { }).superRefine((plan, context) => {
const ids = new Set(plan.nodes.map((node) => node.id)); const ids = new Set(plan.nodes.map((node) => node.id));
if (ids.size !== plan.nodes.length) context.addIssue({ code: "custom", message: "Duplicate node ID" }); if (ids.size !== plan.nodes.length) context.addIssue({ code: "custom", message: "Duplicate node ID" });
const edges = new Set<string>(); const edges = new Set<string>();
const edgeIds = new Set<string>();
for (const edge of plan.edges) { for (const edge of plan.edges) {
const key = `${edge.from}:${edge.to}`; const key = `${edge.from}:${edge.to}`;
if (!ids.has(edge.from) || !ids.has(edge.to) || edge.from === edge.to || edges.has(key)) { if (!ids.has(edge.from) || !ids.has(edge.to) || edge.from === edge.to || edges.has(key)) {
context.addIssue({ code: "custom", message: "Invalid or duplicate dependency" }); context.addIssue({ code: "custom", message: "Invalid or duplicate dependency" });
} }
edges.add(key); edges.add(key);
if (plan.schema === "execution-plan@2") {
if (!edge.id || edgeIds.has(edge.id)) context.addIssue({ code: "custom", message: "V2 edges require unique IDs" });
if (edge.id) edgeIds.add(edge.id);
}
} }
const visiting = new Set<string>(); const visiting = new Set<string>();
const visited = new Set<string>(); const visited = new Set<string>();
@@ -37,6 +47,35 @@ export const executionPlanSchema = z.object({
return false; return false;
}; };
if (plan.nodes.some((node) => visit(node.id))) context.addIssue({ code: "custom", message: "Dependencies must be acyclic; use a new node for a retry" }); if (plan.nodes.some((node) => visit(node.id))) context.addIssue({ code: "custom", message: "Dependencies must be acyclic; use a new node for a retry" });
if (plan.schema === "execution-plan@2") {
if (plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)).length !== 1) {
context.addIssue({ code: "custom", message: "V2 plan requires one start node" });
}
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
for (const node of plan.nodes) {
const outgoing = plan.edges.filter((edge) => edge.from === node.id);
const incoming = plan.edges.filter((edge) => edge.to === node.id);
if (!node.kind) context.addIssue({ code: "custom", message: "V2 nodes require kind" });
if (node.kind === "decision") {
if (outgoing.length < 2 || outgoing.some((edge) => !edge.label.trim())) context.addIssue({ code: "custom", message: "Decision routes must be labeled" });
if (node.selected_edge_id && !outgoing.some((edge) => edge.id === node.selected_edge_id)) context.addIssue({ code: "custom", message: "Selected route must leave its decision" });
if (node.status === "completed" && !node.selected_edge_id) context.addIssue({ code: "custom", message: "Completed decision requires a selected route" });
if (node.selected_edge_id) {
for (const edge of outgoing) {
const targetStatus = nodeById.get(edge.to)?.status;
if (edge.id === node.selected_edge_id && ["skipped", "cancelled"].includes(targetStatus ?? "")) context.addIssue({ code: "custom", message: "Selected route cannot be skipped" });
if (edge.id !== node.selected_edge_id && ["running", "completed", "waiting"].includes(targetStatus ?? "")) context.addIssue({ code: "custom", message: "Unchosen route cannot be active" });
}
}
} else if (node.selected_edge_id) context.addIssue({ code: "custom", message: "Only decisions select routes" });
if (node.kind !== "decision" && node.kind !== "parallel_split" && outgoing.length > 1) context.addIssue({ code: "custom", message: "Only decisions or parallel splits may branch" });
if (node.kind === "parallel_split" && outgoing.length < 2) context.addIssue({ code: "custom", message: "Parallel split requires two routes" });
if (node.kind === "parallel_join" && incoming.length < 2) context.addIssue({ code: "custom", message: "Parallel join requires two inputs" });
if (node.kind === "gate" && !node.gate_ref) context.addIssue({ code: "custom", message: "Gate requires a persisted reference" });
if (node.kind !== "gate" && node.gate_ref) context.addIssue({ code: "custom", message: "Only gates reference approvals" });
if (node.revisits_node_id && !ids.has(node.revisits_node_id)) context.addIssue({ code: "custom", message: "Revisited node must exist" });
}
}
for (const node of plan.nodes) { for (const node of plan.nodes) {
if (node.status === "running" || node.status === "completed") { if (node.status === "running" || node.status === "completed") {
const hasIncompletePredecessor = plan.edges const hasIncompletePredecessor = plan.edges
@@ -50,11 +89,21 @@ export const executionPlanSchema = z.object({
message: "Running or completed nodes require completed or skipped predecessors", message: "Running or completed nodes require completed or skipped predecessors",
}); });
} }
if (node.status === "completed" && node.evidence.length === 0 && node.action_refs.length === 0) context.addIssue({ code: "custom", message: "Completed nodes require evidence or an approved action receipt" }); if (node.status === "completed" && node.evidence.length === 0 && node.action_refs.length === 0
&& !(plan.schema === "execution-plan@2" && ["parallel_split", "parallel_join", "gate"].includes(node.kind ?? ""))) {
context.addIssue({ code: "custom", message: "Completed nodes require evidence or an approved action receipt" });
}
if ((node.status === "waiting" || node.status === "failed") && !node.detail.trim()) context.addIssue({ code: "custom", message: "Waiting or failed nodes require a reason" }); if ((node.status === "waiting" || node.status === "failed") && !node.detail.trim()) context.addIssue({ code: "custom", message: "Waiting or failed nodes require a reason" });
} }
}); });
// Stored v1 plans remain readable, but every newly published task plan must
// opt into the graph and approval-gate guarantees of v2.
export const newExecutionPlanSchema = executionPlanSchema.refine(
(plan) => plan.schema === "execution-plan@2",
{ message: "New task execution plans must use execution-plan@2", path: ["schema"] },
);
export const executionPlanNeedsTaskPublish = (input: unknown) => export const executionPlanNeedsTaskPublish = (input: unknown) =>
!(input && typeof input === "object" && (input as Record<string, unknown>).server_published === true); !(input && typeof input === "object" && (input as Record<string, unknown>).server_published === true);
@@ -84,5 +133,5 @@ export function createExecutionPlanSnapshot(input: unknown, messages: unknown[],
} }
} }
} }
return { ...plan, schema_version: "execution-plan@1", session_id: sessionId, revision: history.length + 1, updated_at: new Date().toISOString() }; return { ...plan, schema_version: plan.schema, session_id: sessionId, revision: history.length + 1, updated_at: new Date().toISOString() };
} }
+23
View File
@@ -19,6 +19,7 @@ import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import { import {
operationalTaskClient, operationalTaskClient,
operationalTaskDataQuerySchema, operationalTaskDataQuerySchema,
expressAccessRequestSchema,
humanTaskEvidenceCommandSchema, humanTaskEvidenceCommandSchema,
proposedTaskActionSchema, proposedTaskActionSchema,
taskArtifactBridgeSchema, taskArtifactBridgeSchema,
@@ -153,6 +154,28 @@ app.post("/internal/operational-task/data-query", async (req, res) => {
} }
}); });
app.post("/internal/operational-task/access-requests", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
if (!bridge || bridge.context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
}
const parsed = expressAccessRequestSchema.safeParse(req.body?.request);
if (!parsed.success) {
res.status(400).json({ message: "invalid access request" });
return;
}
try {
res.json(await operationalTaskClient.requestDataAccess(bridge.runtimeSessionId, bridge.taskId, {
...parsed.data, sourceSessionId: bridge.context.clientSessionId,
}));
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number"
? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : "access request failed" });
}
});
app.post("/internal/operational-task/actions", async (req, res) => { app.post("/internal/operational-task/actions", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req); const bridge = readOperationalTaskBridgeContext(req);
if (!bridge) { if (!bridge) {
+2 -2
View File
@@ -96,7 +96,7 @@ describe("OpenCode internal bridge tools", () => {
"-e", "-e",
[ [
'import tool from "./.opencode/tools/publish_execution_plan.ts";', 'import tool from "./.opencode/tools/publish_execution_plan.ts";',
'const result = await tool.execute({ request_id: "60000000-0000-4000-8000-000000000001", title: "调查", reason: "开始", nodes: [{ id: "read", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [] }], edges: [] }, { sessionID: "runtime-session-1" });', 'const result = await tool.execute({ request_id: "60000000-0000-4000-8000-000000000001", schema: "execution-plan@2", title: "调查", reason: "开始", nodes: [{ id: "read", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [], kind: "activity" }], edges: [] }, { sessionID: "runtime-session-1" });',
"process.stdout.write(result);", "process.stdout.write(result);",
].join("\n"), ].join("\n"),
], { ], {
@@ -141,7 +141,7 @@ describe("OpenCode internal bridge tools", () => {
"-e", "-e",
[ [
'import tool from "./.opencode/tools/publish_execution_plan.ts";', 'import tool from "./.opencode/tools/publish_execution_plan.ts";',
'await tool.execute({ request_id: "60000000-0000-4000-8000-000000000001", title: "调查", reason: "开始", nodes: [{ id: "read", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [] }], edges: [] }, { sessionID: "runtime-session-1" });', 'await tool.execute({ request_id: "60000000-0000-4000-8000-000000000001", schema: "execution-plan@2", title: "调查", reason: "开始", nodes: [{ id: "read", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [], kind: "activity" }], edges: [] }, { sessionID: "runtime-session-1" });',
].join("\n"), ].join("\n"),
], { ], {
cwd: process.cwd(), cwd: process.cwd(),
+2 -1
View File
@@ -32,7 +32,8 @@ describe("agent event trigger", () => {
expect(prompt).toContain("水质监测点"); expect(prompt).toContain("水质监测点");
expect(prompt).toContain("二次供水"); expect(prompt).toContain("二次供水");
expect(prompt).toContain("合成演练数据"); expect(prompt).toContain("合成演练数据");
expect(prompt).toContain("疑似二次供水环节问题(待现场核验)"); expect(prompt).toContain("动态规划本次调查");
expect(prompt).not.toContain("请按‘上游数据质量正常");
expect(prompt).toContain(trigger.trigger_id); expect(prompt).toContain(trigger.trigger_id);
}); });
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { createExecutionPlanSnapshot, executionPlanNeedsTaskPublish, executionPlanSchema, readExecutionPlans } from "../../src/routes/executionPlan.js"; import { createExecutionPlanSnapshot, executionPlanNeedsTaskPublish, executionPlanSchema, newExecutionPlanSchema, readExecutionPlans } from "../../src/routes/executionPlan.js";
import { streamPromptResponse } from "../../src/routes/chatStream.js"; import { streamPromptResponse } from "../../src/routes/chatStream.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
@@ -13,6 +13,38 @@ const plan = {
}; };
describe("execution plans", () => { describe("execution plans", () => {
it("requires v2 for newly published task plans while retaining legacy reads", () => {
const legacy = {
title: "Legacy plan",
reason: "Stored before graph plans",
nodes: [{ id: "inspect", title: "Inspect", status: "pending", owner: "agent", detail: "", evidence: [] }],
edges: [],
};
expect(executionPlanSchema.parse(legacy).schema).toBe("execution-plan@1");
expect(newExecutionPlanSchema.safeParse(legacy).success).toBe(false);
});
it("accepts explicit v2 branches and structural joins without invented evidence", () => {
const v2 = {
schema: "execution-plan@2", title: "动态调查", reason: "工单请求现场及数据并行核验",
nodes: [
{ id: "split", title: "并行启动", status: "completed", owner: "agent", detail: "同时核查", evidence: [], kind: "parallel_split" },
{ id: "field", title: "现场采样", status: "waiting", owner: "human", detail: "等待采样", evidence: [], kind: "activity" },
{ id: "data", title: "监测数据", status: "completed", owner: "agent", detail: "已核对", evidence: ["e-1"], kind: "activity" },
{ id: "join", title: "证据汇合", status: "waiting", owner: "agent", detail: "等待现场", evidence: [], kind: "parallel_join" },
],
edges: [
{ id: "field-route", from: "split", to: "field", label: "现场" },
{ id: "data-route", from: "split", to: "data", label: "数据" },
{ id: "field-join", from: "field", to: "join", label: "" },
{ id: "data-join", from: "data", to: "join", label: "" },
],
};
const parsed = executionPlanSchema.parse(v2);
expect(createExecutionPlanSnapshot(parsed, [], "session-1").schema_version).toBe("execution-plan@2");
expect(executionPlanSchema.safeParse({ ...v2, edges: [{ ...v2.edges[0], id: "data-route" }, ...v2.edges.slice(1)] }).success).toBe(false);
});
it("rejects missing dependencies, cycles, duplicate IDs and unsupported completion", () => { it("rejects missing dependencies, cycles, duplicate IDs and unsupported completion", () => {
for (const invalid of [ for (const invalid of [
{ ...plan, edges: [{ from: "unknown", to: "sample", label: "" }] }, { ...plan, edges: [{ from: "unknown", to: "sample", label: "" }] },