feat(todo-form): 冒泡待办支持form_schema动态表单——ask_question可声明上传/填写字段,人类在待办里直接完成

This commit is contained in:
ymq 2026-09-01 12:38:54 +08:00
parent 52550c9e69
commit 14cbf208b7
2 changed files with 66 additions and 5 deletions

View File

@ -551,7 +551,7 @@ AGENT_TOOLS = [
{"name":"git_clone","description":"克隆git仓库到机构工作空间(应用→apps/、模块→modules/)","params":{"repo_url":"仓库URL","repo_name":"仓库目录名(可选,默认从URL推断,_app后缀→apps/)","branch":"分支(可选,默认main)"}}, {"name":"git_clone","description":"克隆git仓库到机构工作空间(应用→apps/、模块→modules/)","params":{"repo_url":"仓库URL","repo_name":"仓库目录名(可选,默认从URL推断,_app后缀→apps/)","branch":"分支(可选,默认main)"}},
{"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}}, {"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}},
{"name":"git_commit_push","description":"git add + commit + push(develop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push,非 git 目录自动 init)","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}}, {"name":"git_commit_push","description":"git add + commit + push(develop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push,非 git 目录自动 init)","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}},
{"name":"ask_question","description":"向用户提问(缺少信息时使用)","params":{"question":"问题"}}, {"name":"ask_question","description":"向用户提问(缺少信息时使用)。需要用户提供文件或结构化信息时用 form 声明动态表单,用户可在待办里直接上传/填写完成","params":{"question":"问题","form":"可选,JSON:{\"fields\":[{\"name\":\"字段名\",\"type\":\"file|text|textarea\",\"label\":\"提示\",\"target\":\"文件目标相对路径(如 env/test.json,目录以/结尾保留原文件名)\",\"required\":true}]}"}},
{"name":"deliver","description":"提交最终交付件(代码/文档文件已用write_file写好时调用)","params":{"deliverable_type":"交付件类型(如code_files/design_doc)","summary":"概述","result":"交付件正文","files":"JSON数组[{\"path\":\"modules/{模块}/src/x.py\",\"content\":\"代码内容\"}](可选)"}}, {"name":"deliver","description":"提交最终交付件(代码/文档文件已用write_file写好时调用)","params":{"deliverable_type":"交付件类型(如code_files/design_doc)","summary":"概述","result":"交付件正文","files":"JSON数组[{\"path\":\"modules/{模块}/src/x.py\",\"content\":\"代码内容\"}](可选)"}},
] ]
@ -978,13 +978,18 @@ async def _bubble_deploy_env_missing(sor, project_id, task_id, task_title, missi
_own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id}) _own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01' owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01'
# env_file 是绝对路径({pdir}/env/{env}.json),target 取项目内相对路径
_rel = 'env/'
_m = re.search(r'/env/([^/]+)$', env_file or '')
if _m:
_rel = 'env/' + _m.group(1)
question = ( question = (
f"部署任务「{task_title or task_id[:8]}」无法开始:真实部署环境信息尚未提供。" f"部署任务「{task_title or task_id[:8]}」无法开始:真实部署环境信息尚未提供。"
"这属于外部输入(agent 无法自行产出),因此不回退需求/设计/开发阶段,只等这里补齐。\n\n" "这属于外部输入(agent 无法自行产出),因此不回退需求/设计/开发阶段,只等这里补齐。\n\n"
f"配置文件:{env_file}\n\n" f"配置文件:{env_file}\n\n"
"缺失字段:\n" + "\n".join(f" · {m}" for m in missing) + "缺失字段:\n" + "\n".join(f" · {m}" for m in missing) +
"\n\n请补齐以上字段后提交回答(回答内容会随任务上下文交给执行 agent," "\n\n**请直接在下方上传该环境配置文件**(含完整部署信息的 JSON;"
"任务将自动恢复执行、重新校验环境信息)。" "提交后任务自动恢复执行、重新校验环境信息)。"
) )
await raise_problem( await raise_problem(
"need_info", question, "system.orchestrator", "need_info", question, "system.orchestrator",
@ -992,6 +997,11 @@ async def _bubble_deploy_env_missing(sor, project_id, task_id, task_title, missi
first_handler_role="owner.superuser", first_handler_agentid=owner_id, first_handler_role="owner.superuser", first_handler_agentid=owner_id,
context={"env_file": env_file, "missing": missing}, context={"env_file": env_file, "missing": missing},
suspend_task=True, suspend_task=True,
form_schema={"fields": [{
"name": "env_file", "type": "file",
"label": f"环境配置文件({_rel})——需含以下字段:" + "、".join(missing),
"target": _rel, "required": True, "accept": ".json",
}]},
) )
logger.warning(f"部署环境信息缺失冒泡(问题通道): task={task_id} missing={missing} owner={owner_id}") logger.warning(f"部署环境信息缺失冒泡(问题通道): task={task_id} missing={missing} owner={owner_id}")
@ -1783,6 +1793,45 @@ async def _rollback_task_chain(sor, project_id, task_id, rollback_role, comment)
# ── Agent 工具执行 ── # ── Agent 工具执行 ──
def _parse_form_schema(v):
"""解析 ask_question 的 form 参数 → 规整的 form_schema(非法/空 → None)。
LLM 可能传 JSON 字符串或 dict;字段只保留白名单键,防注入。
返回 {"fields":[{name,type,label,target,required,accept},...]} 或 None。
"""
if not v:
return None
try:
schema = json.loads(v) if isinstance(v, str) else v
fields = schema.get('fields') if isinstance(schema, dict) else None
if not isinstance(fields, list) or not fields:
return None
out = []
for f in fields[:10]: # 最多 10 个字段
if not isinstance(f, dict):
continue
name = str(f.get('name') or '').strip()
ftype = str(f.get('type') or 'text').strip().lower()
if not name or ftype not in ('file', 'text', 'textarea'):
continue
target = str(f.get('target') or '').strip()
if target:
# target 只允许相对路径字符,禁绝对路径与 .. 穿越
if target.startswith('/') or '..' in target.split('/'):
target = ''
out.append({
'name': name[:40],
'type': ftype,
'label': str(f.get('label') or name)[:120],
'target': target[:200],
'required': bool(f.get('required')),
'accept': str(f.get('accept') or '')[:60],
})
return {"fields": out} if out else None
except Exception:
return None
def _parse_agent_action(raw): def _parse_agent_action(raw):
raw = (raw or "").strip() raw = (raw or "").strip()
if raw.startswith("```"): if raw.startswith("```"):
@ -2239,6 +2288,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
deliverable = None deliverable = None
ask_question = None ask_question = None
ask_form = None # ask_question 附带的动态表单声明(form_schema)
written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底) written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底)
# ── Tool Loop(原生 function calling)── # ── Tool Loop(原生 function calling)──
@ -2287,6 +2337,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
break break
if tool == "ask_question": if tool == "ask_question":
ask_question = params.get("question", "") ask_question = params.get("question", "")
ask_form = _parse_form_schema(params.get("form"))
break break
if tool == "load_skill": if tool == "load_skill":
@ -2309,6 +2360,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
break break
elif act.get('action') == 'ask': elif act.get('action') == 'ask':
ask_question = act.get('question', '') ask_question = act.get('question', '')
ask_form = _parse_form_schema(act.get('form'))
break break
elif act.get('action') == 'tool_call': elif act.get('action') == 'tool_call':
tool = act.get('tool', '') tool = act.get('tool', '')
@ -2333,7 +2385,8 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
from_agentid=agent_id, from_agentid=agent_id,
tenant_id=project_id, task_id=task_id, tenant_id=project_id, task_id=task_id,
first_handler_role="agent.main_agent", first_handler_role="agent.main_agent",
context={"title": title}) context={"title": title},
form_schema=ask_form)
return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": ask_question} return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": ask_question}
if not deliverable: if not deliverable:

View File

@ -40,7 +40,7 @@ def _get_db():
async def raise_problem(problem_type, question, from_role, from_agentid="", async def raise_problem(problem_type, question, from_role, from_agentid="",
tenant_id=None, task_id=None, context=None, tenant_id=None, task_id=None, context=None,
first_handler_role=None, first_handler_agentid="", first_handler_role=None, first_handler_agentid="",
suspend_task=True) -> str: suspend_task=True, form_schema=None) -> str:
"""提出问题。首处理方由调用方(读 skill 后)显式指定。 """提出问题。首处理方由调用方(读 skill 后)显式指定。
Args: Args:
@ -51,6 +51,10 @@ async def raise_problem(problem_type, question, from_role, from_agentid="",
first_handler_role: 首处理角色(不传默认 main_agent) first_handler_role: 首处理角色(不传默认 main_agent)
first_handler_agentid: 首处理方具体 agent(空=该角色任意 agent) first_handler_agentid: 首处理方具体 agent(空=该角色任意 agent)
suspend_task: 是否把任务置 waiting(角色提问挂起等回答) suspend_task: 是否把任务置 waiting(角色提问挂起等回答)
form_schema: 动态表单声明(可选)。{"fields":[{"name","type":"file|text|textarea",
"label","target","required","accept"}]}——待办详情按它渲染输入界面,
人类在待办里直接上传文件/填写信息完成任务(文件落项目工作空间,
路径随回答回流给 agent)。存进 context.form_schema。
Returns: Returns:
question_id question_id
@ -65,6 +69,10 @@ async def raise_problem(problem_type, question, from_role, from_agentid="",
hr = first_handler_role or "agent.main_agent" hr = first_handler_role or "agent.main_agent"
ha = first_handler_agentid or "" ha = first_handler_agentid or ""
qid = getID() qid = getID()
# form_schema 并入 context 存储(待办详情按它渲染动态表单)
if form_schema:
context = dict(context or {})
context['form_schema'] = form_schema
ctx_json = json.dumps(context, ensure_ascii=False, default=str) if context else None ctx_json = json.dumps(context, ensure_ascii=False, default=str) if context else None
await sor.C('pipeline_agent_questions', { await sor.C('pipeline_agent_questions', {
'id': qid, 'id': qid,