feat: 岗位会商独立意图智能体与四项意图 schema
岗位会商 Step 1 改用 position-roundtable-intent 智能体,输出 coreGoals/riskWarnings/keyPoints/strategicSignificance 四项数组;支持单轮多张协助卡、按轮次计澄清上限,并兼容旧会话 objective/constraints 映射。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ca32a22c0d
commit
f8757d1a43
24 files changed
+1258
-257
No files matched your search
@@ -123,6 +123,27 @@ cascade pragmas. The router is mounted canonically below
|
||||
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
|
||||
Clients may retry the compatibility route only for a framework/proxy-level
|
||||
`404 Not Found`; domain-level missing-session responses must remain real 404s.
|
||||
|
||||
Position roundtable selects the separately seeded built-in agent
|
||||
`position-roundtable-intent` by passing `intent_agent_id` to the shared
|
||||
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
|
||||
files, and SSE protocol match `roundtable-intent`, but its cap is five
|
||||
**user-assistance rounds**. A single round may emit several independent
|
||||
`ask_clarification` tool calls; those calls are separate cards, not one card
|
||||
containing several numbered questions. Count that as one round by the
|
||||
tool-calling AI message, never by card count. Topic-only requests must request
|
||||
all material missing decisions as separate cards rather than being treated as a
|
||||
resolved intent; after the fifth set is answered it must emit completed final
|
||||
JSON without unresolved placeholders. The required arrays are
|
||||
`coreGoals`, `riskWarnings`, `keyPoints`, and `strategicSignificance`. Do not
|
||||
treat a fresh dedicated-agent `ask_clarification` as stale: it must win over
|
||||
a same-run premature `[INTENT_READY]` and leave the UI waiting for the user.
|
||||
Conversely, the prior turn’s historical clarification must not block the user
|
||||
reply from completing. Do not
|
||||
change `roundtable-intent`'s `{objective,constraints,assumptions}` contract for
|
||||
this feature. The position router accepts the new schema for activation and
|
||||
injects all four sections into downstream position-node and closing-agent
|
||||
context; legacy stored summaries are mapped read-only for compatibility.
|
||||
`/api/intent/init` must create the empty Step-1 thread through
|
||||
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
|
||||
do not restore the old HTTP loopback to `/api/threads`, because deployed
|
||||
@@ -436,6 +457,15 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
### Roundtable business-chain human validation
|
||||
|
||||
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
|
||||
boolean `human_validation` field on each seat (`ChainSeatSchema`). It is a
|
||||
frontend Step 2 checkpoint, not a server-side approval task: after the marked
|
||||
seat completes (or, in a DAG, after its stage completes), the client pauses and
|
||||
uses the existing resume button or intervention composer to continue. No new
|
||||
database column, migration, or validation card is required.
|
||||
|
||||
The position-collaboration workspace is a separate frontend route and must not
|
||||
use the original roundtable coordinator or jobs. Its shared configuration
|
||||
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
|
||||
|
||||
@@ -123,7 +123,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
|
||||
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
@@ -481,6 +481,24 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
||||
|
||||
Its live intent conversation still uses the shared `/api/intent/init` and
|
||||
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
|
||||
Position roundtable identifies itself with `intent_agent_id: "position-roundtable-intent"`;
|
||||
this is a separately seeded built-in agent, not an alias of `roundtable-intent`.
|
||||
It preserves the same thread, SSE streaming, and file-reading lifecycle as the
|
||||
generic Step-1 agent, but has its own **at-most-five user-assistance-round**
|
||||
brake. A round is one AI turn plus the user reply, not one tool call: the
|
||||
dedicated agent may emit several independent `ask_clarification` calls in the
|
||||
same turn, which the frontend renders as several separate selection cards and
|
||||
submits together. A topic-only request is not considered resolved: the agent
|
||||
asks for every independent missing decision (such as scope, decision use, or
|
||||
task constraints) as separate cards. For the dedicated agent, a fresh
|
||||
`ask_clarification` emitted in a stream is a strict user-input pause and wins over any same-run premature
|
||||
`[INTENT_READY]`; a historical clarification from the prior turn does not
|
||||
block the user answer from completing. Its final
|
||||
`[INTENT_READY]` JSON contains four string arrays: `coreGoals`, `riskWarnings`,
|
||||
`keyPoints`, and `strategicSignificance`; it must contain completed conclusions,
|
||||
not “待确认” or other unresolved placeholders. The multi-agent endpoint remains
|
||||
backwards compatible when this optional field is omitted and continues to emit
|
||||
`objective`, `constraints`, and `assumptions`.
|
||||
`/api/intent/init` creates its empty thread directly through the in-process
|
||||
thread store/checkpointer, matching multi-agent initialization and avoiding a
|
||||
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. If initial checkpoint
|
||||
|
||||
@@ -58,6 +58,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
# Position roundtable deliberately owns a separate intent agent. Its dialogue
|
||||
# lifecycle is shared with Step 1 above, but its final structured hand-off is
|
||||
# different and must not change the multi-agent roundtable contract.
|
||||
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
@@ -92,6 +96,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
@@ -109,14 +114,14 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
"""Copy the verified SOUL.md + config.yaml for one functional agent into
|
||||
``.deer-flow/agents/<id>/`` if that directory is missing.
|
||||
|
||||
Returns True if the files were just created, False if the directory already
|
||||
existed (we never overwrite — respect any local edits) or the seed assets
|
||||
are missing.
|
||||
"""
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
return False
|
||||
The position-roundtable intent agent is an implementation-owned compatibility
|
||||
contract: unlike user-managed agents, its schema must stay aligned with the
|
||||
position workspace. Its two seed files are therefore refreshed when they
|
||||
differ, so a previously seeded incorrect prompt is repaired after deployment.
|
||||
|
||||
Returns True if files were created or refreshed, False if no work was needed
|
||||
or the seed assets are missing.
|
||||
"""
|
||||
src_dir = _ASSETS_DIR / agent_id
|
||||
if not src_dir.is_dir():
|
||||
# Assets should always ship with the code; if they don't, surface a loud
|
||||
@@ -128,6 +133,25 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
if agent_id != POSITION_INTENT_AGENT_ID:
|
||||
# Other built-in agents may have been tuned on a deployed machine;
|
||||
# preserve the long-standing no-overwrite behavior for them.
|
||||
return False
|
||||
|
||||
refreshed = False
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
if not src.exists():
|
||||
continue
|
||||
target = agent_dir / fname
|
||||
source_bytes = src.read_bytes()
|
||||
if not target.exists() or target.read_bytes() != source_bytes:
|
||||
target.write_bytes(source_bytes)
|
||||
refreshed = True
|
||||
return refreshed
|
||||
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
@@ -138,9 +162,9 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
|
||||
def ensure_roundtable_functional_agents() -> list[str]:
|
||||
"""Seed the three roundtable functional agents (``roundtable-intent`` /
|
||||
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
|
||||
directories are missing. Idempotent — fast no-op when all exist.
|
||||
"""Seed the built-in roundtable functional agents when their on-disk
|
||||
directories are missing, and refresh the implementation-owned position
|
||||
intent schema when it has changed. Idempotent after synchronization.
|
||||
|
||||
Called from the startup lifespan and from the entry of intent/init,
|
||||
intent/stream, recommend/stream, and multi_agent/init so the routes
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# 岗位会商任务意图理解智能体
|
||||
|
||||
你是岗位多智能体会商 Step 1 的“意图理解专员”。你的唯一职责是把用户输入背后的任务意图结构化后交给后续岗位业务链处理。
|
||||
|
||||
你不写报告、不答题、不创作、不规划、不执行、不调用任何操作工具。这些都是后续岗位业务链的事。你只负责听懂、整理、澄清和交接。
|
||||
|
||||
---
|
||||
|
||||
## 0. 四条不可违背的硬规则
|
||||
|
||||
### 硬规则 1 · 不替代后续岗位干活
|
||||
|
||||
无论用户让你“写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案”,你都不要直接产出具体内容。
|
||||
|
||||
- 正确做法:把诉求结构化成任务意图,输出 [INTENT_READY],交给后续岗位业务链。
|
||||
- 错误做法:用户说“帮我写一份市场分析报告”后,开始写报告正文。
|
||||
|
||||
### 硬规则 2 · 最多 5 轮用户协助,确认完整后才收敛
|
||||
|
||||
一次“同一轮中调用一个或多个 ask_clarification + 用户统一回答”算一轮用户协助。
|
||||
|
||||
- 首轮:若任务卡和用户输入已经足以形成可执行的四项意图,可直接输出 [INTENT_READY]。
|
||||
- 第 1 至第 5 轮用户协助:若信息不足,必须发起用户协助;不能把“主题已识别”误判为“意图已明确”。
|
||||
- 同一轮可发起多张用户协助卡:一个相互独立的决策点对应一次 ask_clarification 调用和一张卡。不得把多个问题塞进一张卡;用户可逐卡选择后一次性提交。
|
||||
- 第 5 次协助应覆盖所有剩余关键项。收到该轮回答后,必须基于明确答案收敛为最终意图。
|
||||
- 绝不进入第 6 次用户协助。系统会在达到上限后移除 ask_clarification。
|
||||
|
||||
### 硬规则 3 · 输出 [INTENT_READY] 后立刻闭嘴
|
||||
|
||||
最终 JSON 代码块闭合后,禁止再输出任何字符:不继续解释、不问“还需要补充吗”、不调用工具、不复述、不展望。
|
||||
|
||||
### 硬规则 4 · 只做理解和澄清,不当守门员
|
||||
|
||||
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 任务意图字段定义
|
||||
|
||||
最终结构必须包含以下四个字段,且都必须是字符串数组:
|
||||
|
||||
| 字段 | 含义 | 填写原则 |
|
||||
| --- | --- | --- |
|
||||
| coreGoals | 用户想实现或想获得的核心结果 | 将原本的一句话任务目标拆为 1 个或多个清晰目标 |
|
||||
| riskWarnings | 已知风险、依赖或执行风险 | 必须分析任务级风险;范围、地区、周期、数据来源、对象或决策用途缺失时,应先通过用户协助确认,最终结果不得保留待确认表述 |
|
||||
| keyPoints | 范围、对象、优先级、时间、资源、交付形式等关键要点 | 仅汇总用户已明确的事实与结论,最终结果不得保留待确认项 |
|
||||
| strategicSignificance | 任务对后续岗位协同、业务链或决策推进的意义 | 必须分析该任务将支撑哪类后续决策、业务链协同或资源投入;可以作出任务级合理推断,但不得虚构组织现状或业务数据 |
|
||||
|
||||
核心原则:
|
||||
|
||||
- 用户已给出任务目标、范围、用途和约束时,直接汇总,不要追问。
|
||||
- “只给出一个主题或一句极短诉求”不等于任务意图完整。任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助。
|
||||
- 同一轮应识别所有仍会影响后续业务链的关键缺口,并为每个相互独立的缺口各调用一次 ask_clarification,避免一个问题一轮地消耗用户;不要把它们合并为一张多题卡。
|
||||
- 最终 [INTENT_READY] 中禁止出现“待确认”“未明确”“需补充”“暂无”“缺少”等未完成表述;信息不足时继续协助,不得提前交接。
|
||||
- 空数组只适用于用户明确说明“不涉及/没有”这一项时。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流
|
||||
|
||||
~~~
|
||||
用户输入
|
||||
↓
|
||||
是否能形成可执行的四项意图?
|
||||
├─ 能 → 立即输出 [INTENT_READY],结束
|
||||
└─ 不能 → 同一轮可分别调多个 ask_clarification(每张卡只问一个决策点)
|
||||
↓
|
||||
用户回答 → 再判断
|
||||
↓
|
||||
最多 5 轮用户协助 → 收到明确答案后输出 [INTENT_READY]
|
||||
~~~
|
||||
|
||||
### 提问前自检清单
|
||||
|
||||
任一项为“否”,就应发起用户协助;同轮把全部为“否”的独立决策点分别做成卡片:
|
||||
|
||||
1. 我是否不仅能写出核心目标,也能明确主要分析范围或决策用途?
|
||||
2. 我是否能基于任务给出至少一项任务级风险提示和战略意义?
|
||||
3. 用户是否已经在任务卡、当前消息或历史消息中给出这些关键信息?
|
||||
4. 这些缺口是否已经在本轮协助中一次性列出?
|
||||
5. 是否已达到第 5 次用户协助?若是,等待用户对最后一组问题的回答,不再发起第 6 次协助。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一允许的工具:ask_clarification
|
||||
|
||||
除 ask_clarification 外,禁止调用任何工具、技能或操作。尤其禁止搜索、写文件、读写沙箱、展示文件、创建任务、编排智能体或生成交付物。
|
||||
|
||||
当任务只有主题、或缺少决定范围/用途/风险判断的关键信息时,必须调用它。每个协助问题只包含一个决策点;同一轮需要确认多个点时,连续调用多次工具,生成多张独立选择卡:
|
||||
|
||||
~~~
|
||||
ask_clarification(
|
||||
question="本次分析主要面向哪个范围?",
|
||||
clarification_type="analysis_scope",
|
||||
context="范围会决定后续岗位的分析边界",
|
||||
options=["选项 A", "选项 B", "其他,我补充说明"],
|
||||
allow_multiple=False
|
||||
)
|
||||
~~~
|
||||
|
||||
若还需要确认交付用途、时间边界、对象、风险限制等,必须在同一次回复中继续分别调用 ask_clarification,而不是将它们写进上面这张卡。不得重复询问用户已经回答过的问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 结束协议:[INTENT_READY]
|
||||
|
||||
可以收敛时,先用 1–2 句话、30 字以内复述理解,然后严格以 JSON 代码块结束。JSON 代码块后不得有任何内容。
|
||||
|
||||
输出示例的文本顺序必须是:
|
||||
|
||||
~~~
|
||||
✅ 我理解的任务是:<一句话复述>
|
||||
|
||||
[INTENT_READY]
|
||||
JSON 代码块(语言标识 json):
|
||||
{
|
||||
"coreGoals": ["..."],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": ["..."],
|
||||
"strategicSignificance": []
|
||||
}
|
||||
~~~
|
||||
|
||||
格式要求:
|
||||
|
||||
- [INTENT_READY] 单独成行。
|
||||
- [INTENT_READY] 后紧跟语言标识为 json 的 Markdown 代码块,包裹合法 JSON 对象。
|
||||
- 四个字段名称必须严格为 coreGoals / riskWarnings / keyPoints / strategicSignificance。
|
||||
- 四个字段必须都是字符串数组;除用户明确说明不涉及外,不要输出空数组。
|
||||
- 输出前逐项检查:四个数组中不得出现“待确认”“未明确”“需补充”“暂无”“缺少”等词语。
|
||||
- JSON 必须严格合法:英文双引号、无注释、无尾随逗号;字符串中不可直接换行。
|
||||
- JSON 代码块闭合后,禁止再写任何字符。
|
||||
- 不得输出旧字段 objective、constraints、assumptions。
|
||||
|
||||
---
|
||||
|
||||
## 5. 禁止事项
|
||||
|
||||
- 禁止直接产出报告、代码、诗、方案、分析或任何交付物。
|
||||
- 禁止调用任何非 ask_clarification 的工具。
|
||||
- 禁止第 5 次用户协助后再发起第 6 次协助。
|
||||
- 禁止重复询问用户已回答的问题。
|
||||
- 禁止拒绝用户、要求用户换问题或以风险为由中断。
|
||||
- 禁止编造外部事实、组织现状或业务数据;信息缺口必须在用户协助阶段确认,最终只输出明确的执行风险和战略意义。
|
||||
- 禁止在 [INTENT_READY] 后再输出一个字。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
id: position-roundtable-intent
|
||||
name: 岗位会商任务意图理解
|
||||
description: 岗位会商专用的任务意图理解智能体。通过必要的简短追问,将任务整理为核心目标、风险提示、关键要点与战略意义,供后续岗位业务链执行。
|
||||
@@ -34,6 +34,7 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
多卡回合还带 ``clarifications`` 完整数组;顶层字段始终镜像第一张卡以兼容旧客户端。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
@@ -65,7 +66,10 @@ from app.gateway.roundtable_diag import record_foreground_diag
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
_auth_headers,
|
||||
@@ -87,6 +91,7 @@ router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
|
||||
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
@@ -96,7 +101,49 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
|
||||
"""Resolve the explicitly supported Step-1 agent without opening a route
|
||||
for arbitrary custom-agent execution.
|
||||
|
||||
Existing multi-agent callers omit the field and therefore keep using
|
||||
``roundtable-intent``. Position roundtable sends the dedicated ID so its
|
||||
prompt and final JSON schema remain isolated.
|
||||
"""
|
||||
requested = str(payload.get("intent_agent_id") or "").strip()
|
||||
if not requested:
|
||||
return INTENT_AGENT_ID
|
||||
if requested not in _SUPPORTED_INTENT_AGENT_IDS:
|
||||
raise HTTPException(status_code=400, detail="Unsupported intent agent")
|
||||
return requested
|
||||
|
||||
|
||||
def _clarification_blocks_ready(
|
||||
intent_agent_id: str,
|
||||
clarification_calls: Any,
|
||||
*,
|
||||
is_fresh: bool,
|
||||
) -> bool:
|
||||
"""Position intent must wait for a fresh clarification answer.
|
||||
|
||||
The generic Step-1 route intentionally preserves READY-first behavior for
|
||||
stale tool calls in historical snapshots. Position roundtable instead has
|
||||
a four-part hand-off whose missing context is requested interactively, so
|
||||
a newly dispatched clarification is a strict terminal pause. A historical
|
||||
tool call from the preceding user-assistance turn must not block the next
|
||||
user answer from reaching READY.
|
||||
"""
|
||||
return (
|
||||
intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
and bool(clarification_calls)
|
||||
and is_fresh
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(
|
||||
task_context: dict[str, Any] | None,
|
||||
*,
|
||||
intent_agent_id: str = INTENT_AGENT_ID,
|
||||
) -> dict[str, Any]:
|
||||
"""Build task-scoped metadata for a newly-created intent thread.
|
||||
|
||||
Normal roundtable Step 1 calls still use the original system-thread tag.
|
||||
@@ -122,6 +169,8 @@ def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, An
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
||||
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -190,6 +239,32 @@ def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _count_past_clarification_rounds(messages: list[dict[str, Any]]) -> int:
|
||||
"""Count completed assistance *rounds*, rather than question cards.
|
||||
|
||||
The generic roundtable agent retains its historic three-tool-call brake.
|
||||
Position roundtable deliberately lets one model turn emit several
|
||||
independent ``ask_clarification`` cards, so using the tool-call count
|
||||
there would accidentally spend all five user-assistance rounds at once.
|
||||
One AI message containing one or more clarification calls is one round.
|
||||
"""
|
||||
if not isinstance(messages, list):
|
||||
return 0
|
||||
rounds = 0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
if any(
|
||||
(tc.get("name") or "").lower() == "ask_clarification"
|
||||
for tc in msg.get("tool_calls") or []
|
||||
if isinstance(tc, dict)
|
||||
):
|
||||
rounds += 1
|
||||
return rounds
|
||||
|
||||
|
||||
async def _fetch_thread_messages(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
@@ -225,8 +300,18 @@ async def _fetch_thread_messages(
|
||||
|
||||
|
||||
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
|
||||
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
|
||||
# 通用会商沿用 3 次工具调用的历史保护;岗位会商按"一轮可多张卡"计数,
|
||||
# 允许最多 5 次用户协助。
|
||||
_MAX_CLARIFICATION_ROUNDS = 3
|
||||
_POSITION_MAX_CLARIFICATION_ROUNDS = 5
|
||||
|
||||
|
||||
def _max_clarification_rounds(intent_agent_id: str) -> int:
|
||||
return (
|
||||
_POSITION_MAX_CLARIFICATION_ROUNDS
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else _MAX_CLARIFICATION_ROUNDS
|
||||
)
|
||||
|
||||
|
||||
def _is_repeated_clarification(
|
||||
@@ -310,6 +395,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
|
||||
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
|
||||
ensure_roundtable_functional_agents()
|
||||
intent_agent_id = _requested_intent_agent_id(payload)
|
||||
|
||||
# Keep Step-1 initialization aligned with multi-agent initialization:
|
||||
# create the empty thread in-process rather than looping back through
|
||||
@@ -321,7 +407,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
try:
|
||||
thread_id = await _create_thread_direct(
|
||||
request,
|
||||
metadata=_intent_thread_metadata(task_context),
|
||||
metadata=_intent_thread_metadata(task_context, intent_agent_id=intent_agent_id),
|
||||
)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
@@ -341,7 +427,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
raise HTTPException(status_code=500, detail=diagnostic) from exc
|
||||
return {
|
||||
"status": "success",
|
||||
"agent_id": INTENT_AGENT_ID,
|
||||
"agent_id": intent_agent_id,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
@@ -363,6 +449,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
}
|
||||
"""
|
||||
thread_id = str(payload.get("thread_id") or "").strip()
|
||||
intent_agent_id = _requested_intent_agent_id(payload)
|
||||
message = str(payload.get("message") or "").strip()
|
||||
# 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
||||
@@ -386,21 +473,30 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
fallback_instruction = (
|
||||
"四个岗位会商字段都必须保留为字符串数组,依据已获得的用户确认形成明确结论,"
|
||||
"不得写待确认、未明确、需补充、暂无或缺少"
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else "缺的字段用 assumptions 兜底"
|
||||
)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
|
||||
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# —— 3 轮硬上限兜底 ——
|
||||
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
|
||||
# past=0/1 → 正常,不动 message,不动工具白名单
|
||||
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
|
||||
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
|
||||
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
|
||||
past_clarifications = _count_past_clarifications(
|
||||
await _fetch_thread_messages(client, base, headers, thread_id)
|
||||
# —— 澄清轮次硬上限兜底 ——
|
||||
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
|
||||
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
|
||||
# "提问 + 用户回答")计轮,不能按卡片数计轮。
|
||||
past_messages = await _fetch_thread_messages(client, base, headers, thread_id)
|
||||
past_clarifications = _count_past_clarifications(past_messages)
|
||||
past_rounds = (
|
||||
_count_past_clarification_rounds(past_messages)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else past_clarifications
|
||||
)
|
||||
max_rounds = _max_clarification_rounds(intent_agent_id)
|
||||
|
||||
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
|
||||
base_excluded_tools = [
|
||||
@@ -420,13 +516,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
"task",
|
||||
"write_todos",
|
||||
]
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
if past_rounds >= max_rounds:
|
||||
base_excluded_tools.append("ask_clarification")
|
||||
logger.info(
|
||||
"intent stream: past_clarifications=%d >= %d, force-disabling "
|
||||
"intent stream: past_rounds=%d >= %d, force-disabling "
|
||||
"ask_clarification to break the loop",
|
||||
past_clarifications,
|
||||
_MAX_CLARIFICATION_ROUNDS,
|
||||
past_rounds,
|
||||
max_rounds,
|
||||
)
|
||||
|
||||
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
|
||||
@@ -440,22 +536,42 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
||||
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
|
||||
# The position flow needs an actionable four-part hand-off,
|
||||
# not merely a recognizable topic. One turn may ask several
|
||||
# independent decisions, each rendered as its own selection
|
||||
# card by the existing Step-1 UI.
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
|
||||
"[岗位会商收敛检查] 仅识别出主题不等于意图已明确。"
|
||||
"若当前任务还无法判断分析范围、决策用途、关键风险或战略意义,"
|
||||
"请在同一次回复中为每个相互独立的关键缺口分别调用一次 "
|
||||
"ask_clarification;每个工具调用只能问一个决策点,不能把多项"
|
||||
"问题合并成一张卡片。用户会一次性回答这些卡片。"
|
||||
"如果四项意图已经足够明确,则直接输出最终结果。"
|
||||
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
|
||||
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
|
||||
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
|
||||
f"用户最新输入:{effective_message}"
|
||||
) % (past_rounds + 1, max_rounds, max_rounds)
|
||||
if past_rounds >= max_rounds:
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_rounds} 轮,触发硬上限。"
|
||||
f"本轮请直接输出 [INTENT_READY],{fallback_instruction},"
|
||||
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
|
||||
elif (
|
||||
intent_agent_id != POSITION_INTENT_AGENT_ID
|
||||
and past_rounds >= max_rounds - 1
|
||||
):
|
||||
effective_message = (
|
||||
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
|
||||
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
|
||||
f"[系统提示] 已澄清 {past_rounds} 轮,这是最后一轮。"
|
||||
f"本轮请直接输出 [INTENT_READY],{fallback_instruction}。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
agent_name=intent_agent_id,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=effective_message,
|
||||
@@ -479,6 +595,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
current_rounds = (
|
||||
_count_past_clarification_rounds(messages)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
else _count_past_clarifications(messages)
|
||||
)
|
||||
clarification_is_fresh = current_rounds > past_rounds
|
||||
|
||||
# A position-roundtable clarification is an explicit pause.
|
||||
# Some models emit a premature [INTENT_READY] text after the
|
||||
# tool call in the same upstream run. For this dedicated flow
|
||||
# the fresh clarification must win so the user can answer it;
|
||||
# generic roundtable keeps its historical READY-first order.
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
clarification_calls = [
|
||||
tc
|
||||
for tc in tool_calls
|
||||
if isinstance(tc, dict)
|
||||
and (tc.get("name") or "").lower() == "ask_clarification"
|
||||
]
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
@@ -507,7 +643,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
|
||||
ready_text = msg_text[: m.end()].rstrip() + "\n"
|
||||
break
|
||||
if summary and ready_text:
|
||||
if (
|
||||
summary
|
||||
and ready_text
|
||||
and not _clarification_blocks_ready(
|
||||
intent_agent_id,
|
||||
clarification_calls,
|
||||
is_fresh=clarification_is_fresh,
|
||||
)
|
||||
):
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
@@ -519,42 +663,49 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
if clarification_calls:
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
|
||||
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
|
||||
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
|
||||
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
|
||||
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
clarification_payloads: list[dict[str, Any]] = []
|
||||
for clarification_call in clarification_calls:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# 逐卡过滤已被用户回答过的重复问题,不能因为其中一张
|
||||
# 重复卡而吞掉同轮其它有效的协助卡。
|
||||
if _is_repeated_clarification(
|
||||
messages,
|
||||
new_question=question,
|
||||
new_options=cargs.get("options"),
|
||||
new_tool_call_id=clarification_call.get("id"),
|
||||
):
|
||||
logger.warning(
|
||||
"intent stream: dropping duplicate ask_clarification "
|
||||
"(question=%r) — already asked & answered earlier in thread",
|
||||
question[:80],
|
||||
)
|
||||
continue
|
||||
clarification_payloads.append(
|
||||
{
|
||||
"id": str(clarification_call.get("id") or ""),
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
}
|
||||
)
|
||||
|
||||
if not clarification_payloads:
|
||||
fallback_msg = (
|
||||
display_content
|
||||
follow_up
|
||||
or content
|
||||
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
|
||||
)
|
||||
final_frame: dict[str, Any] = {
|
||||
final_frame = {
|
||||
"status": "asking",
|
||||
"content": fallback_msg,
|
||||
"summary": None,
|
||||
@@ -562,15 +713,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
first = clarification_payloads[0]
|
||||
display_content = follow_up or content or str(first["question"])
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
# 兼容旧客户端保留首张卡的顶层字段;完整列表使得网络
|
||||
# 重连、缓存重放等没有实时 tool frame 的路径也能还原多卡。
|
||||
**first,
|
||||
"clarifications": clarification_payloads,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
@@ -470,15 +470,64 @@ def _markdown_delivery_changed(
|
||||
return bool(new_signatures - old_signatures)
|
||||
|
||||
|
||||
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
|
||||
"""Return a clean, display-safe string list from a persisted intent field."""
|
||||
value = intent.get(key)
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
||||
|
||||
|
||||
def _position_intent_sections(intent: Any) -> dict[str, list[str]]:
|
||||
"""Read both the dedicated position-intent schema and legacy snapshots.
|
||||
|
||||
New position sessions persist the four position-specific fields. Mapping
|
||||
the legacy Step-1 fields keeps already-created sessions executable after
|
||||
this rollout instead of stranding them at the activation gate.
|
||||
"""
|
||||
if not isinstance(intent, dict):
|
||||
return {
|
||||
"coreGoals": [],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": [],
|
||||
"strategicSignificance": [],
|
||||
}
|
||||
has_position_schema = any(
|
||||
key in intent
|
||||
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
)
|
||||
if has_position_schema:
|
||||
return {
|
||||
"coreGoals": _intent_string_list(intent, "coreGoals"),
|
||||
"riskWarnings": _intent_string_list(intent, "riskWarnings"),
|
||||
"keyPoints": _intent_string_list(intent, "keyPoints"),
|
||||
"strategicSignificance": _intent_string_list(intent, "strategicSignificance"),
|
||||
}
|
||||
|
||||
objective = _text_from_keys(intent, ["objective", "goal"])
|
||||
return {
|
||||
"coreGoals": _intent_string_list(intent, "constraints"),
|
||||
"riskWarnings": [],
|
||||
"keyPoints": _intent_string_list(intent, "assumptions"),
|
||||
"strategicSignificance": [objective] if objective else [],
|
||||
}
|
||||
|
||||
|
||||
def _intent_node_answer(intent: Any) -> str:
|
||||
if not isinstance(intent, dict):
|
||||
return "情报分析岗已完成任务意图识别。"
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
|
||||
if constraints:
|
||||
lines.append("关键约束:")
|
||||
lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
sections = _position_intent_sections(intent)
|
||||
lines = ["情报分析岗已完成任务意图识别。"]
|
||||
labels = (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
)
|
||||
for label, values in labels:
|
||||
if values:
|
||||
lines.append(f"{label}:")
|
||||
lines.extend(f"- {item}" for item in values)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -486,6 +535,15 @@ def _has_confirmed_intent(intent: Any) -> bool:
|
||||
"""Only structured Step-1 output may start a frozen business chain."""
|
||||
if not isinstance(intent, dict):
|
||||
return False
|
||||
if any(
|
||||
key in intent
|
||||
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
):
|
||||
required = ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
|
||||
if not all(isinstance(intent.get(key), list) for key in required):
|
||||
return False
|
||||
sections = _position_intent_sections(intent)
|
||||
return bool(sections["coreGoals"] or sections["strategicSignificance"])
|
||||
objective = _text_from_keys(intent, ["objective", "goal"])
|
||||
constraints = intent.get("constraints")
|
||||
return bool(objective and isinstance(constraints, list))
|
||||
@@ -535,8 +593,7 @@ def _build_node_context_text(
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
sections = _position_intent_sections(intent)
|
||||
agent_name, position_id = _seat_meta(session, current)
|
||||
md_required = not _is_intent_position_node(current, _session_intent_position_id(session))
|
||||
lines = [
|
||||
@@ -548,11 +605,16 @@ def _build_node_context_text(
|
||||
f"- 任务描述:{description}",
|
||||
"",
|
||||
"【已识别意图】",
|
||||
f"- 任务目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
lines.append("- 关键约束:")
|
||||
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
for label, values in (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
):
|
||||
if values:
|
||||
lines.append(f"- {label}:")
|
||||
lines.extend(f" - {item}" for item in values)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
@@ -1005,8 +1067,7 @@ async def _build_builtin_prompt(
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
sections = _position_intent_sections(intent)
|
||||
stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
|
||||
stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
|
||||
material = await _effective_delivery_material(session, nodes)
|
||||
@@ -1020,11 +1081,16 @@ async def _build_builtin_prompt(
|
||||
f"- 描述:{description}",
|
||||
"",
|
||||
"【情报分析岗已确认的意图】",
|
||||
f"- 核心目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
common.append("- 关键约束:")
|
||||
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
for label, values in (
|
||||
("核心目标", sections["coreGoals"]),
|
||||
("风险提示", sections["riskWarnings"]),
|
||||
("关键要点", sections["keyPoints"]),
|
||||
("战略意义", sections["strategicSignificance"]),
|
||||
):
|
||||
if values:
|
||||
common.append(f"- {label}:")
|
||||
common.extend(f" - {item}" for item in values)
|
||||
if stage_lines:
|
||||
common.extend(["", "【业务链阶段】", *stage_lines])
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ class ChainSeatSchema(BaseModel):
|
||||
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
|
||||
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
|
||||
seat_skill_directive: bool = False
|
||||
# Per-seat human checkpoint. Step 2 pauses after this seat (or its DAG
|
||||
# stage) completes and reuses the existing resume/intervention controls.
|
||||
# It is stored in the existing seats JSON, so no database migration is needed.
|
||||
human_validation: bool = False
|
||||
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
|
||||
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
|
||||
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
|
||||
|
||||
@@ -14,9 +14,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.gateway import deps as gateway_deps
|
||||
from app.gateway import services as gateway_services
|
||||
from app.gateway.app import create_app
|
||||
from app.gateway.routers import _roundtable_seed as roundtable_seed
|
||||
from app.gateway.routers import intent as intent_router
|
||||
from app.gateway.routers import multi_agent
|
||||
from app.gateway.routers import position_roundtable as position_roundtable_router
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
required_agent_ids,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||
@@ -55,6 +60,111 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
|
||||
assert position_roundtable_router.router.prefix == "/position-roundtable"
|
||||
|
||||
|
||||
def test_position_intent_agent_is_a_seeded_functional_agent():
|
||||
"""Fresh deployments must materialize the dedicated position intent agent."""
|
||||
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
|
||||
|
||||
|
||||
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
|
||||
"""Dedicated schema must not regress into solutioning or gatekeeping."""
|
||||
prompt = (
|
||||
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
for phrase in (
|
||||
"最多 5 轮用户协助",
|
||||
"除 ask_clarification 外,禁止调用任何工具、技能或操作",
|
||||
"你都不拒绝、不评价、不设限",
|
||||
"不得说“不能帮你”“不在职责范围内”“请换一个问题”",
|
||||
"coreGoals / riskWarnings / keyPoints / strategicSignificance",
|
||||
"不能把“主题已识别”误判为“意图已明确”",
|
||||
"任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助",
|
||||
"必须分析任务级风险",
|
||||
"同一轮可发起多张用户协助卡",
|
||||
"不得把多个问题塞进一张卡",
|
||||
):
|
||||
assert phrase in prompt
|
||||
|
||||
|
||||
def test_position_intent_clarification_waits_for_the_user_before_ready():
|
||||
"""A same-run READY block must not consume the user-assistance pause."""
|
||||
assert intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
None,
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=False,
|
||||
)
|
||||
|
||||
|
||||
def test_position_intent_counts_multi_card_assistance_as_one_round():
|
||||
"""Five user-assistance rounds must not be consumed by five cards at once."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{"id": "scope", "name": "ask_clarification", "args": {}},
|
||||
{"id": "purpose", "name": "ask_clarification", "args": {}},
|
||||
{"id": "time", "name": "ask_clarification", "args": {}},
|
||||
],
|
||||
},
|
||||
{"type": "human", "content": "用户统一答复"},
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{"id": "risk", "name": "ask_clarification", "args": {}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert intent_router._count_past_clarifications(messages) == 4
|
||||
assert intent_router._count_past_clarification_rounds(messages) == 2
|
||||
assert intent_router._max_clarification_rounds(intent_router.INTENT_AGENT_ID) == 3
|
||||
assert (
|
||||
intent_router._max_clarification_rounds(intent_router.POSITION_INTENT_AGENT_ID)
|
||||
== 5
|
||||
)
|
||||
|
||||
|
||||
def test_position_intent_seed_repairs_its_contract_without_touching_other_agents(tmp_path, monkeypatch):
|
||||
"""The dedicated schema prompt must replace an accidentally seeded old version."""
|
||||
agents_dir = tmp_path / "agents"
|
||||
monkeypatch.setattr(
|
||||
roundtable_seed,
|
||||
"get_paths",
|
||||
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
|
||||
)
|
||||
|
||||
position_dir = agents_dir / POSITION_INTENT_AGENT_ID
|
||||
position_dir.mkdir(parents=True)
|
||||
(position_dir / "SOUL.md").write_text("outdated prompt", encoding="utf-8")
|
||||
assert roundtable_seed._ensure_one(POSITION_INTENT_AGENT_ID) is True
|
||||
expected_soul = (
|
||||
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert (position_dir / "SOUL.md").read_text(encoding="utf-8") == expected_soul
|
||||
|
||||
generic_dir = agents_dir / roundtable_seed.INTENT_AGENT_ID
|
||||
generic_dir.mkdir(parents=True)
|
||||
generic_soul = generic_dir / "SOUL.md"
|
||||
generic_soul.write_text("deployed generic tune", encoding="utf-8")
|
||||
assert roundtable_seed._ensure_one(roundtable_seed.INTENT_AGENT_ID) is False
|
||||
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
|
||||
"""Step 1 initialization must not depend on the deployed Gateway port."""
|
||||
@@ -91,6 +201,71 @@ async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_position_intent_init_selects_its_dedicated_agent(monkeypatch):
|
||||
"""Position roundtable must never mutate the generic Step-1 agent contract."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
|
||||
|
||||
async def fake_create_thread_direct(request, *, metadata):
|
||||
captured["metadata"] = metadata
|
||||
return "position-intent-thread"
|
||||
|
||||
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
|
||||
|
||||
result = await intent_router.init_intent(
|
||||
SimpleNamespace(),
|
||||
{
|
||||
"intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
"task_context": {"task": {"id": "task-position-001"}},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "success",
|
||||
"agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
"thread_id": "position-intent-thread",
|
||||
}
|
||||
assert captured["metadata"] == {
|
||||
**intent_router._ROUNDTABLE_THREAD_METADATA,
|
||||
"taskId": "task-position-001",
|
||||
"position_roundtable_task_id": "task-position-001",
|
||||
"position_task_id": "task-position-001:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
"position_roundtable_intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
|
||||
}
|
||||
|
||||
|
||||
def test_position_intent_schema_is_required_for_new_snapshots_and_legacy_is_kept():
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{
|
||||
"coreGoals": ["形成统一处置方案"],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": ["需要在本周完成"],
|
||||
"strategicSignificance": ["为后续岗位协同提供统一边界"],
|
||||
}
|
||||
)
|
||||
assert not position_roundtable_router._has_confirmed_intent(
|
||||
{"coreGoals": ["形成统一处置方案"], "riskWarnings": []}
|
||||
)
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{"objective": "历史任务", "constraints": []}
|
||||
)
|
||||
answer = position_roundtable_router._intent_node_answer(
|
||||
{
|
||||
"coreGoals": ["形成统一处置方案"],
|
||||
"riskWarnings": ["关键数据尚待确认"],
|
||||
"keyPoints": ["聚焦当前任务范围"],
|
||||
"strategicSignificance": ["支撑业务链协同推进"],
|
||||
}
|
||||
)
|
||||
assert "核心目标" in answer
|
||||
assert "风险提示" in answer
|
||||
assert "关键要点" in answer
|
||||
assert "战略意义" in answer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
created = await store.create_session(
|
||||
|
||||
@@ -116,3 +116,13 @@ async def test_unknown_mode_value_ignored_falls_through(_gw):
|
||||
await _run(gateway)
|
||||
assert captured["thinking_enabled"] is True
|
||||
assert captured["reasoning_effort"] == "low"
|
||||
|
||||
|
||||
def test_chain_seat_schema_keeps_human_validation_checkpoint():
|
||||
"""Chain-seat JSON must preserve the frontend Step 2 pause configuration."""
|
||||
from app.gateway.routers.roundtable_chains import ChainSeatSchema
|
||||
|
||||
assert ChainSeatSchema(agent_id="seat-1").human_validation is False
|
||||
assert ChainSeatSchema(agent_id="seat-1", human_validation=True).model_dump()[
|
||||
"human_validation"
|
||||
] is True
|
||||
Reference in new issue
Block a user