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_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}},
{"name":"git_commit_push","description":"git add + commit + pushdevelop 用它提交模块仓库本地提交作为产出证据;无远程只 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\":\"代码内容\"}](可选)"}},
]
@ -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})
await sor.sqlExe("COMMIT", {})
owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01'
# env_file 是绝对路径({pdir}/env/{env}.jsontarget 取项目内相对路径
_rel = 'env/'
_m = re.search(r'/env/([^/]+)$', env_file or '')
if _m:
_rel = 'env/' + _m.group(1)
question = (
f"部署任务「{task_title or task_id[:8]}」无法开始:真实部署环境信息尚未提供。"
"这属于外部输入agent 无法自行产出),因此不回退需求/设计/开发阶段,只等这里补齐。\n\n"
f"配置文件:{env_file}\n\n"
"缺失字段:\n" + "\n".join(f" · {m}" for m in missing) +
"\n\n请补齐以上字段后提交回答(回答内容会随任务上下文交给执行 agent"
"任务自动恢复执行、重新校验环境信息)。"
"\n\n**请直接在下方上传该环境配置文件**(含完整部署信息的 JSON"
"提交后任务自动恢复执行、重新校验环境信息)。"
)
await raise_problem(
"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,
context={"env_file": env_file, "missing": missing},
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}")
@ -1783,6 +1793,45 @@ async def _rollback_task_chain(sor, project_id, task_id, rollback_role, comment)
# ── 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):
raw = (raw or "").strip()
if raw.startswith("```"):
@ -2239,6 +2288,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
deliverable = None
ask_question = None
ask_form = None # ask_question 附带的动态表单声明form_schema
written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底)
# ── Tool Loop原生 function calling──
@ -2287,6 +2337,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
break
if tool == "ask_question":
ask_question = params.get("question", "")
ask_form = _parse_form_schema(params.get("form"))
break
if tool == "load_skill":
@ -2309,6 +2360,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
break
elif act.get('action') == 'ask':
ask_question = act.get('question', '')
ask_form = _parse_form_schema(act.get('form'))
break
elif act.get('action') == 'tool_call':
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,
tenant_id=project_id, task_id=task_id,
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}
if not deliverable:

View File

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