feat(todo-form): 冒泡待办支持form_schema动态表单——ask_question可声明上传/填写字段,人类在待办里直接完成
This commit is contained in:
parent
52550c9e69
commit
14cbf208b7
@ -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 + 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\":\"代码内容\"}](可选)"}},
|
||||
]
|
||||
|
||||
@ -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}.json),target 取项目内相对路径
|
||||
_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:
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user