Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6048,7 +6048,7 @@
},
"nodeStates": {
"additionalProperties": true,
"description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts.",
"description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots.",
"type": "object"
},
"pluginVersion": {
Expand Down
14 changes: 13 additions & 1 deletion docs/workflows/workflows.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ after upgrading. No bindings still produce an empty object, and committed termin
recomputed. Publication admission cannot retroactively protect old snapshots; inspect their Output
bindings before upgrading.

## Agent inputs and outputs

Before dispatch, an Agent prompt uses the Condition template renderer for `{{#node.root#}}`, reading
committed variables in its current scope and frozen globals. Missing variables, invalid paths and
unterminated templates fail the node without sending unresolved placeholders to the model. Retries
render again; loop and iteration members read their current round's variables.
After the initial turn succeeds and the session terminal evidence commits, the final complete visible
answer is stored in the Agent state's `output.output`, exposed as `{agent}.output` to bindings.
Tool preambles, other turns and older attempts do not become deliverables. No answer leaves the root
unassigned; truncated history is never published as a complete answer. Execution evidence lives in
`sessionEndReason`, outside the variable roots. Already terminal historical runs are not backfilled.

## Executor slots

Agent nodes do not hard-code an Agent CLI or model ID in the graph; they reference an **executor slot**:
Expand Down Expand Up @@ -227,7 +239,7 @@ seconds):
(`maxRetries` ranges 0–5 and defaults to 2), and the next attempt starts after `initialDelaySeconds × 2^(n-1)`
(`initialDelaySeconds` ranges 0–300 and defaults to 10, with a 600-second cap per wait). While it waits, the node
commits `retrying`: the run stays `running`, the node keeps the failed attempt's `startedAt`, `error` and
`output.reason`, records `retryAt` from the database clock and clears `finishedAt`; it keeps the run's single
`sessionEndReason`, records `retryAt` from the database clock and clears `finishedAt`; it keeps the run's single
session slot until `retryAt` arrives. The new attempt has its own execution identity (`workflow_node_attempt`), so
the previous attempt's result and end request are never reused. A spent budget, a disabled policy or a reason
outside `agent_failed` fails the node with the last reason, and the run reaches a terminal state and releases its
Expand Down
10 changes: 9 additions & 1 deletion docs/workflows/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ Output 检查器可添加、命名和删除 `data.outputs` 绑定,从该节点
缺值时以 `output_value_unset` 失败,路径非法时以 `output_invalid_path` 失败。因此升级后旧快照和在途恢复可能出现新的失败或新增输出。
没有绑定仍得到空对象;已经提交的终态结果不会因升级重新计算。发布门禁不能追溯保护旧快照,升级前应检查其中的 Output 绑定。

## Agent 输入与输出

Agent 的 prompt 在派发前复用 Condition 的模板实现渲染 `{{#node.root#}}`,读取已提交的当前作用域变量和冻结全局变量。
缺失变量、非法路径或未闭合模板使节点失败,不把原始占位符发送给模型。重试重新渲染,loop / iteration 使用当前轮的变量。
初始回合成功结束且会话终态提交后,最后一条完整可见回答写入 Agent 节点的 `output.output`,供 `{agent}.output` 绑定读取。
工具前说明、其他回合和旧尝试的回答不进入产物;没有回答时保持未赋值,截断 history 不作为完整回答发布。
结束证据保存在节点的 `sessionEndReason`,不占用变量根;历史已结束运行保持原状,不回填输出。

## 执行器槽位

Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执行器槽位**:
Expand Down Expand Up @@ -151,7 +159,7 @@ WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce
- **Agent 节点重试由冻结在节点上的 `agentConfig.retry` 决定。** 只有会话以 `agent_failed` 结束、策略启用且预算未用完时才重试:预算是
`maxRetries + 1` 次尝试(`maxRetries` 取值 0–5,默认 2),下一次尝试在 `initialDelaySeconds × 2^(n-1)` 之后开始(`initialDelaySeconds`
取值 0–300,默认 10,单次等待上限 600 秒)。等待期间节点提交 `retrying`:运行仍是 `running`,节点保留失败尝试的 `startedAt`、
`error` 和 `output.reason`,写入数据库时钟计算的 `retryAt` 并清空 `finishedAt`;它继续占用运行唯一的会话位置,直到 `retryAt` 到达才启动
`error` 和 `sessionEndReason`,写入数据库时钟计算的 `retryAt` 并清空 `finishedAt`;它继续占用运行唯一的会话位置,直到 `retryAt` 到达才启动
下一次尝试。新尝试使用自己的执行身份(`workflow_node_attempt`),上一次尝试的结果和结束请求不会被它复用。预算用完、策略关闭或原因不在
`agent_failed` 内时,节点以最后一次的原因失败,运行进入终态并释放 Workspace。发布时 `agentConfig.retry` 必须是完整对象,缺失或为 `null`
表示默认策略,未知键忽略;已冻结的历史快照不重新校验,无法解析的策略按默认值执行。
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/generated.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2681,7 +2681,7 @@ export type WorkflowRunDefinitionSnapshot = { [key: string]: unknown } | null;
export type WorkflowRunInput = { [key: string]: unknown };

/**
* Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts.
* Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots.
*/
export type WorkflowRunNodeStates = { [key: string]: unknown };

Expand Down Expand Up @@ -2727,7 +2727,7 @@ export interface WorkflowRun {
*/
modelBindings?: WorkflowRunModelBinding[] | null;
name: string;
/** Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. */
/** Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots. */
nodeStates: WorkflowRunNodeStates;
/**
* The agent plugin version frozen into the run at creation: the frozen identity the run's agent sessions execute with. Null only when the space declared no plugin at creation (or the run predates migration 0042); the first agent node then fails with `agent_plugin_version_unfrozen`.
Expand Down
2 changes: 1 addition & 1 deletion internal/contract/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func Document() map[string]any {
workflowRunProps["status"] = enumeration("pending", "running", "succeeded", "failed", "cancelled")
workflowRunProps["pluginVersion"] = optional(obj{"type": "string", "description": "The agent plugin version frozen into the run at creation: the frozen identity the run's agent sessions execute with. Null only when the space declared no plugin at creation (or the run predates migration 0042); the first agent node then fails with `agent_plugin_version_unfrozen`."})
workflowRunProps["input"] = obj{"type": "object", "additionalProperties": true, "description": "The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead."}
workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts."}
workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots."}
workflowRunProps["rounds"] = array(obj{"type": "object", "additionalProperties": true})
// definitionSnapshot lives only on a detail read (the history list trips without it);
// it is optional so one schema serves both list rows and the composed run page.
Expand Down
64 changes: 64 additions & 0 deletions internal/core/workflow_agent_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package core

// assignWorkflowAgentOutput reads only durable, settled history from this node's current
// execution attempt. History updates already contain assembled messages; concatenating them
// would mix tool preambles into the final answer. The initial turn identity excludes later
// turns, and the successful turn boundary excludes interrupted or still-streaming output.
// Session terminal evidence remains required: callers invoke this only while settling it.
func assignWorkflowAgentOutput(t *transaction, runID, nodeID string, states map[string]workflowNodeState) {
state := states[nodeID]
if state.Status != "succeeded" {
return
}
execution := t.one(`SELECT execution_id, input FROM node_executions
WHERE operation_id=$1 AND workflow_node_id=$2 AND workflow_node_attempt=$3
AND kind='agent_session' AND result IS NOT NULL`, runID, nodeID, state.attemptNumber())
if execution == nil {
return
}
turnID := execution.O("input").O("initialTurn").S("turnId")
if turnID == "" {
return
}
ended := t.one(workflowOutputReceiptsSQL+`SELECT sequence, (event->>'record')::jsonb AS record
FROM receipts WHERE event->>'turnId'=$2
AND (event->>'record')::jsonb->>'type'='turnEnded'
ORDER BY sequence DESC LIMIT 1`, execution.S("executionId"), turnID)
if ended == nil || ended.O("record").S("stop_reason") == "cancelled" {
return
}
// A retried prompt in the same execution starts a fresh answer. Never reuse a message
// before its latest user input, even when that attempt produced no assistant text.
message := t.one(workflowOutputReceiptsSQL+`SELECT event, (event->>'record')::jsonb AS record
FROM receipts WHERE event->>'turnId'=$2
AND sequence<$3 AND sequence>COALESCE((
SELECT MAX(sequence) FROM receipts WHERE sequence<$3
AND (event->>'record')::jsonb->'update'->>'sessionUpdate'='user_message_chunk'
),0)
AND (((event->>'record')::jsonb->'update'->>'sessionUpdate'='agent_message_chunk'
AND (event->>'record')::jsonb->'update'->'content'->>'type'='text')
OR event ? 'visibleText')
ORDER BY sequence DESC LIMIT 1`, execution.S("executionId"), turnID, ended.N("sequence"))
if message == nil {
return
}
content := message.O("record").O("update").O("content")
if !message.O("event").B("truncated") && content.S("type") == "text" {
if text := content.S("text"); text != "" {
state.Output = Object{"output": text}
}
} else if message.O("event").B("truncated") {
// The visibleText projection is bounded independently of the history record. It is
// not a substitute for a complete deliverable; never publish clipped text as success.
state.Status = "failed"
state.Error = "agent_output_truncated"
}
states[nodeID] = state
}

// Terminal receipts are opaque bytes, not necessarily JSON. Guard their cast before
// inspecting history, whose record field was validated by takeOverThreadEvents.
const workflowOutputReceiptsSQL = `WITH receipts AS MATERIALIZED (
SELECT sequence, CASE WHEN event IS JSON OBJECT THEN event::jsonb ELSE '{}'::jsonb END AS event
FROM node_event_receipts WHERE execution_id=$1
) `
Loading
Loading