ymq 58d773b6ad fix(workspace): 会话创建项目改用新结构目录 + 写入 directory_name
根因:_t_create_project 用旧结构 build_workspace_path({space}/{项目名}),不写
directory_name;而工作空间读取端已迁到新结构 {space}/projects/{英文slug},
导致会话新建的项目显示「项目目录(不可用)」。

修复:
- 改用 build_space_path 拼 {space}/projects/{slug} 并 os.makedirs
- slug 保留 [A-Za-z0-9_-],须含字母(避免纯数字如'7'),否则兑底 proj_{短ID}
- 写入 directory_name 字段(工作空间读路径依据)
- 同 slug 冲突追加短 ID
2026-08-27 16:11:43 +08:00

1432 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
pipeline-service v2: Agent Executor — 智能执行引擎
对照 Hermes Agent 的 run_conversation() + tool dispatch
- 多轮 tool-calling loopmax_turns 可配置
- Memory + Skills 注入每轮 system prompt
- 上下文压缩(近 token 上限时自动压缩)
- 并行 tool calls同一轮内独立工具同时执行
- 消息数组污染防护tool_call JSON 不进入 LLM 上下文)
- 会话持久化(用户+项目隔离)
- ask_user 澄清机制
- delegate_subtask 子任务分发
使用方法:
executor = AgentExecutor(config, project_id=..., user_id=...)
async for chunk in executor.run("用户输入"):
yield chunk # NDJSON 流式输出
"""
import asyncio
import json
import logging
import os
import time
from typing import AsyncGenerator, Dict, List, Optional
logger = logging.getLogger("pipeline.agent_executor")
from .workspace import WORKSPACE_BASE, GENERAL_SPACE
# ── 默认工具定义(在 pipeline-core 未加载时使用)──
_BUILTIN_ASK_USER_SCHEMA = {
"name": "ask_user",
"description": "向用户提问澄清意图。不确定用户想要什么时使用。",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "要问的问题"},
},
"required": ["question"],
},
}
class AgentExecutor:
"""Agent 执行引擎。
负责:
1. 组装 system prompt配置 + 记忆 + 技能 + 工具)
2. 运行 tool-loopLLM 决策 → 工具执行 → 回传结果)
3. 上下文压缩
4. 会话持久化
"""
def __init__(
self,
config, # AgentConfig (from pipeline_core)
project_id: str = "",
user_id: str = "",
workspace_dir: str = "",
model_name: str = None,
role: str = "", # 产线内角色(可选,如 develop/design驾驶舱 agent 为空)
base_url: str = "", # 请求 base_urlscheme://host/),供 slash 命令生成 widget 绝对 URL
generic: bool = False, # True = 纯通用会话(不解析项目、不挂产线能力)
session=None, # GatewaySession会话级状态approve_all/pending_confirm
session_id: str = "", # 会话内唯一标识web 多 tab 独立会话;历史隔离键)
default_pipeline_id: str = "", # 无当前项目时的默认产线(入口指定,如 bidding_general
):
self.config = config
self.project_id = project_id
self.user_id = user_id
self.org_id = "" # loaded from project context
self.pipeline_id = default_pipeline_id or "" # 默认产线(入口指定);有项目时被项目 pipeline_id 覆盖
self.role = role # 产线内角色(技能/工具/prompt 按角色加载)
self.generic = generic # 是否纯通用会话
self._session = session # GatewaySessionapprove_all/pending_confirm 会话级状态)
self.session_id = session_id # 会话内唯一标识(历史消息按此隔离)
self.space = GENERAL_SPACE # 项目空间键generic→'general',产线→真实 pipeline_id
self.workspace_dir = workspace_dir or WORKSPACE_BASE
self.model_name = model_name or config.model_name
self.base_url = base_url
# 运行状态
self._msgs: List[dict] = [] # LLM 消息数组
self._turn_count: int = 0
self._tool_call_count: int = 0
self._auto_push_count: int = 0 # 限制 auto-inject 次数
self._session_id: str = ""
self._started_at: float = 0.0
self._todos: List[dict] = [] # 会话内任务清单 [{done, content}]
# 懒加载
self._tool_registry = None
self._skill_loader = None
self._memory_store = None
self._compressor = None
self._session_mgr = None
# ═══════════════════════════════════════════════════════
# 主入口
# ═══════════════════════════════════════════════════════
async def run(self, user_input: str, history: List[dict] = None) -> AsyncGenerator[str, None]:
"""执行 agent 主循环。
Args:
user_input: 用户输入
history: 历史消息列表 [{"role":"user"|"assistant","content":"..."}]
Yields:
NDJSON 行:{"type":"progress"|"tool_call"|"reply"|"error", ...}
"""
self._started_at = time.time()
self._turn_count = 0
self._tool_call_count = 0
# Step 0: 初始化(解析 pipeline_id / skill_loader 等slash 命令也需要)
await self._init_components()
# Step 0.5: slash 命令短路(/xxx 直接执行,不进入 LLM 循环)
if user_input and user_input.strip().startswith("/"):
from pipeline_core import resolve_slash_command, parse_slash_args
cmd = resolve_slash_command(user_input.strip(), self.pipeline_id, self.role)
if cmd and cmd.handler:
args = parse_slash_args(user_input.strip())
try:
result = await cmd.handler(args, self._build_slash_ctx())
except Exception as e:
logger.error(f"slash /{cmd.name} error: {e}")
result = f"ERROR: {str(e)[:300]}"
yield json.dumps({"type": "reply", "message": result}, ensure_ascii=False) + "\n"
return
# 未知 slash 命令:提示并列出可用命令
from pipeline_core import get_slash_commands
visible = get_slash_commands(self.pipeline_id, self.role)
help_text = "未知命令。可用: " + ", ".join(f"/{n}" for n in sorted(visible))
yield json.dumps({"type": "reply", "message": help_text}, ensure_ascii=False) + "\n"
return
# Step 2: 加载历史
hist_msgs = await self._load_history(history)
# Step 3: 组装 system prompt
system_prompt = await self._build_system_prompt(user_input)
# Step 4: 构建消息数组
self._msgs = [{"role": "system", "content": system_prompt}]
if hist_msgs:
self._msgs.extend(hist_msgs)
self._msgs.append({"role": "user", "content": user_input})
# Step 4.5: 危险命令确认回复短路pending_confirm + 确认/全部确认/取消)
confirm_decision = self._detect_confirm_decision(user_input)
if confirm_decision and self._session and self._session.pending_confirm:
pending = self._session.pending_confirm
if confirm_decision == "cancel":
self._session.pending_confirm = None
yield json.dumps({"type": "reply", "message": "已取消该操作。"}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, "已取消该操作。")
return
if confirm_decision == "approve_all":
self._session.approve_all = True
self._session.pending_confirm = None
pending_tool = pending.get("tool", "")
pending_params = pending.get("params") or {}
# 回填 assistant tool_call → 执行 → 回填 tool result随后继续 Tool Loop
fake_id = f"call_{int(time.time() * 1000)}"
self._msgs.append({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": fake_id,
"type": "function",
"function": {"name": pending_tool, "arguments": json.dumps(pending_params, ensure_ascii=False)},
}],
})
yield json.dumps({"type": "tool_call", "tool": pending_tool, "params": pending_params}, ensure_ascii=False) + "\n"
result = await self._execute_tool(pending_tool, pending_params)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
self._tool_call_count += 1
yield json.dumps({"type": "tool_result", "tool": pending_tool, "result": result[:500]}, ensure_ascii=False) + "\n"
self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": str(result)})
# 不 return继续进入 Tool Loop让 LLM 基于工具结果继续后续步骤
# Step 5: Tool Loop
yield json.dumps({"type": "progress", "message": "思考中..."}, ensure_ascii=False) + "\n"
final_reply = ""
max_turns = self.config.max_turns
for turn in range(max_turns):
self._turn_count = turn + 1
# 5a. 上下文压缩
if self.config.compression.enabled:
await self._maybe_compress()
# 5b. LLM 调用native function calling返回 dict
resp = await self._call_llm()
# 5c. 原生 function calling优先处理 tool_calls
native_calls = (resp.get("tool_calls") or []) if isinstance(resp, dict) else []
if native_calls:
# 回填 assistant 消息(含 tool_callsOpenAI 原生格式要求)
self._msgs.append({
"role": "assistant",
"content": resp.get("content") or None,
"tool_calls": native_calls,
})
for tc in native_calls:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tool_name = fn.get("name", "")
try:
tool_params = json.loads(fn.get("arguments") or "{}")
except Exception:
tool_params = {}
# 需要确认的工具:记录待确认并暂停,等待用户确认/全部确认/取消
if self._needs_confirmation(tool_name, tool_params):
if self._session:
self._session.pending_confirm = {"tool": tool_name, "params": tool_params}
yield json.dumps({
"type": "confirm", "tool": tool_name, "params": tool_params,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, f"[需确认] {tool_name}")
return
yield json.dumps({
"type": "tool_call", "tool": tool_name, "params": tool_params,
}, ensure_ascii=False) + "\n"
result = await self._execute_tool(tool_name, tool_params)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
self._tool_call_count += 1
yield json.dumps({
"type": "tool_result", "tool": tool_name, "result": result[:500],
}, ensure_ascii=False) + "\n"
# 原生回填role=tool + tool_call_id
self._msgs.append({
"role": "tool",
"tool_call_id": tc.get("id", "") if isinstance(tc, dict) else "",
"content": str(result),
})
continue
# 5d. 文本解析(回退路径,纯文本模型)
raw = resp.get("content", "") if isinstance(resp, dict) else str(resp)
actions = self._parse_actions(raw)
# 5e. 分发处理
for act in actions:
action_type = act.get("action", "")
if action_type == "reply":
# reply 是合法终止动作,直接输出,不再强制注入工具。
# (历史版本会在 tool_call_count==0 时用硬编码关键词强制路由,
# 那会剥夺 LLM 诚实降级/能力自省的能力,把 agent 变成路由器。)
final_reply = act.get("message", "")
yield json.dumps({
"type": "reply", "message": final_reply,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, final_reply)
return
elif action_type == "ask_user":
# ask_user 是合法终止动作:把问题抛给用户,等待回答。
question = act.get("question", "")
yield json.dumps({
"type": "ask_user", "message": question,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, f"[提问] {question}")
return
elif action_type == "tool_call":
tool_name = act.get("tool", "")
tool_params = act.get("params", {})
# 需要确认的工具:记录待确认并暂停,等待用户确认/全部确认/取消
if self._needs_confirmation(tool_name, tool_params):
if self._session:
self._session.pending_confirm = {"tool": tool_name, "params": tool_params}
yield json.dumps({
"type": "confirm",
"tool": tool_name,
"params": tool_params,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, f"[需确认] {tool_name}")
return
# 执行工具
yield json.dumps({
"type": "tool_call", "tool": tool_name,
"params": tool_params,
}, ensure_ascii=False) + "\n"
result = await self._execute_tool(tool_name, tool_params)
if not result.startswith("未知工具") and not result.startswith("ERROR"):
self._tool_call_count += 1
yield json.dumps({
"type": "tool_result", "tool": tool_name,
"result": result[:500],
}, ensure_ascii=False) + "\n"
# 反馈给 LLM关键不存原始 tool_call JSON
self._msgs.append({
"role": "assistant",
"content": f"已调用 {tool_name}",
})
self._msgs.append({
"role": "user",
"content": f"工具 {tool_name} 结果:\n{result}",
})
elif action_type == "error":
final_reply = act.get("message", "处理出错")
yield json.dumps({
"type": "error", "message": final_reply,
}, ensure_ascii=False) + "\n"
return
# 超过 max_turns
final_reply = final_reply or "任务过于复杂,请简化描述后重试。"
yield json.dumps({
"type": "reply", "message": final_reply,
}, ensure_ascii=False) + "\n"
await self._save_turn(user_input, final_reply)
# ═══════════════════════════════════════════════════════
# 初始化
# ═══════════════════════════════════════════════════════
async def _init_components(self):
"""懒加载各组件"""
# 1. 加载 org_id用户机构优先fallback 项目机构)+ workspace_dir + pipeline_id
# 个人选择 llm 按「用户机构」隔离default_llm_id 指向本机构 llm故 org_id 须取 users.orgid
# 项目机构仅作 fallback无 user_id 的测试场景)。
if self.user_id:
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${uid}$", {"uid": self.user_id})
if recs:
self.org_id = getattr(recs[0], 'orgid', '') or ''
except Exception:
pass
if self.project_id:
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT org_id, workspace_dir, pipeline_id FROM sd_projects WHERE id=${pid}$",
{"pid": self.project_id})
if recs:
if not self.org_id:
self.org_id = getattr(recs[0], 'org_id', '') or ''
ws = getattr(recs[0], 'workspace_dir', '') or ''
if ws:
self.workspace_dir = ws
self.pipeline_id = getattr(recs[0], 'pipeline_id', '') or ''
except Exception:
pass
# 2. Tool Registry
try:
from pipeline_core.tool_registry import get_tool_registry
self._tool_registry = get_tool_registry()
# 把 config.tools 注册进 registryregistry 是全局单例,可能为空)
if self._tool_registry and self.config.tools:
existing = set(self._tool_registry.get_tool_names())
for t in self.config.tools:
if t.name not in existing:
self._tool_registry.register(t)
except ImportError:
self._tool_registry = None
# 3. Skill Loader — base_dir 动态解析到机构工作目录(多租户隔离)
try:
from pipeline_core.skill_loader import get_skill_loader
base_dir = await self._resolve_skills_base_dir()
self._skill_loader = get_skill_loader(base_dir)
if base_dir:
self._skill_loader.reload()
except Exception:
self._skill_loader = None
# 4. Memory Store
try:
from pipeline_core.memory_store import get_memory_store
self._memory_store = get_memory_store()
except ImportError:
self._memory_store = None
# 5. pipeline_id 为空时 fallback 到默认产线(保证 slash/ability/skill 统一按产线挂载)
# 入口已指定 default_pipeline_id 时优先用它(投标/开发产线独立入口),否则用引擎默认。
if not self.pipeline_id:
try:
from pipeline_core import DEFAULT_ABILITY_ID
self.pipeline_id = DEFAULT_ABILITY_ID
except ImportError:
pass
# 6. 项目空间键generic→'general'(通用助手),否则用真实 pipeline_id产线之间隔离
self.space = GENERAL_SPACE if self.generic else (self.pipeline_id or GENERAL_SPACE)
async def _resolve_skills_base_dir(self):
"""技能根目录 = 全局 skills/单一技能树所有机构共享读2026-08-21 重构)。"""
try:
from pipeline_core.skill_pack import get_skills_base
return get_skills_base()
except Exception:
return self.config.skills.base_dir
async def _llm_select_skills(self, user_input: str, catalog, max_skills: int) -> List[str]:
"""用 LLM 从技能目录选最相关的 N 个技能(语义匹配,非关键词硬编码)。"""
if not catalog:
return []
catalog_text = "\n".join(f"- {name}: {desc}" for name, desc in catalog)
prompt = (
f"以下是可用技能目录(技能名: 描述):\n{catalog_text}\n\n"
f"用户需求:{user_input}\n\n"
f"请从上述目录中选出与用户需求最相关的至多 {max_skills} 个技能,"
f"只返回技能名的 JSON 数组,如 [\"a\", \"b\"]。没有相关技能返回 []。"
)
try:
from pipeline_service.llm_bridge import llm_call_msgs
import json as _json
import re as _re
content = await llm_call_msgs(
[{"role": "user", "content": prompt}],
model=self.model_name,
temperature=0,
org_id=self.org_id,
)
m = _re.search(r"\[[^\]]*\]", content or "")
if m:
names = _json.loads(m.group(0))
return [n for n in names if isinstance(n, str)]
return []
except Exception as e:
logger.error(f"_llm_select_skills failed: {e}")
return []
async def _build_system_prompt(self, user_input: str) -> str:
"""组装完整 system prompt = 基础 prompt + 记忆 + 技能 + 工具列表"""
prompt = self.config.system_prompt
# 注入当前项目上下文
proj_name = self.project_id
try:
ctx = await self._load_project_context()
if ctx:
proj_name = ctx.replace("项目: ", "")
except Exception:
pass
prompt += f"\n当前项目: {proj_name}\n" if proj_name else ""
# 注入记忆(分域:通用 + 产线 + 项目 叠加)
if self.config.memory.enabled and self._memory_store:
mem_block = await self._memory_store.build_prompt_block(
max_entries=15, scope="pipeline", scope_id=self.pipeline_id)
if self.project_id:
proj_block = await self._memory_store.build_prompt_block(
max_entries=5, scope="project", scope_id=self.project_id)
if proj_block:
mem_block = (mem_block + "\n" + proj_block) if mem_block else proj_block
if mem_block:
prompt += f"\n\n## 持久记忆\n{mem_block}"
# 注入技能六级隔离global→org→pipeline→role→project→user
if self.config.skills.enabled and self._skill_loader:
pipeline_id = self.pipeline_id if self.config.skills.enable_pipeline else ""
role = self.role if self.config.skills.enable_role else ""
project_id = self.project_id if self.config.skills.enable_project else ""
org_id = self.org_id if self.config.skills.enable_org else ""
user_id = self.user_id if self.config.skills.enable_user else ""
# 技能检索必须 LLM 做(语义匹配,非关键词硬编码)
catalog = self._skill_loader.get_skill_catalog(
pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id)
names = await self._llm_select_skills(
user_input, catalog, self.config.skills.max_skills_per_turn)
if names:
skill_block = self._skill_loader.build_prompt_block_by_names(
names, pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id)
else:
# LLM 无结果兜底:按 scope+名字列出前 N 个(不传 user_input走 fallback 分支)
skill_block = self._skill_loader.build_prompt_block(
pipeline_id=pipeline_id, role=role, project_id=project_id,
org_id=org_id, user_id=user_id,
max_skills=self.config.skills.max_skills_per_turn)
if skill_block:
prompt += f"\n\n{skill_block}"
# 注入工具列表(先构建,再替换占位符)
tools_text = ""
if self._tool_registry:
tools_text = self._tool_registry.to_text_description()
else:
lines = []
for t in self.config.tools:
if t.enabled:
params = ", ".join(f"{k}: {v}" for k, v in (t.parameters or {}).items())
lines.append(f"- {t.name}({params}): {t.description}")
tools_text = "\n".join(lines)
prompt = prompt.replace("{project_name}", proj_name)
prompt = prompt.replace("{tools_description}", tools_text)
return prompt
# ═══════════════════════════════════════════════════════
# LLM 调用
# ═══════════════════════════════════════════════════════
async def _call_llm(self) -> dict:
"""调用 LLM返回 {"content": str, "tool_calls": [...]}。
优先使用 OpenAI native function calling。若 registry 不可用或调用失败,
回退到纯文本调用content 为原始文本tool_calls 为空)。
"""
from pipeline_service.llm_bridge import llm_call_msgs_native
tools_schema = None
if self._tool_registry:
try:
tools_schema = self._tool_registry.to_openai_schema()
except Exception as e:
logger.error(f"to_openai_schema failed: {e}")
if tools_schema:
try:
return await llm_call_msgs_native(
self._msgs,
tools=tools_schema,
model=self.model_name,
temperature=self.config.temperature,
org_id=self.org_id,
)
except Exception as e:
logger.error(f"native function calling failed, fallback to text: {e}")
# 回退纯文本调用content 为原始文本)
from pipeline_service.llm_bridge import llm_call_msgs
content = await llm_call_msgs(
self._msgs,
model=self.model_name,
temperature=self.config.temperature,
org_id=self.org_id,
)
return {"content": content or "", "tool_calls": []}
# ═══════════════════════════════════════════════════════
# 解析 LLM 输出
# ═══════════════════════════════════════════════════════
def _parse_actions(self, raw: str) -> List[dict]:
"""解析 LLM 输出,支持单个 JSON 或连续多个 JSON。
对照 HA解析器成熟处理多 JSON + 容错。
"""
raw = (raw or "").strip()
# 去除 markdown 代码块包裹
if raw.startswith("```"):
parts = raw.split("\n", 1)
if len(parts) > 1:
raw = parts[1]
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
results = []
# 尝试多 JSON 解析
# LLM 可能输出: {"action":"tool_call",...}\n{"action":"tool_call",...}
lines = raw.split("\n")
buffer = ""
depth = 0
for line in lines:
line_stripped = line.strip()
if not line_stripped:
if buffer:
# 尝试解析 buffer
result = self._try_parse_json(buffer)
if result:
results.append(result)
buffer = ""
continue
buffer += line_stripped
# 简单大括号计数
depth += line_stripped.count("{") - line_stripped.count("}")
if depth == 0 and buffer:
result = self._try_parse_json(buffer)
if result:
results.append(result)
buffer = ""
# 处理残留
if buffer:
result = self._try_parse_json(buffer)
if result:
results.append(result)
if not results:
# 完全无法解析 JSON → 当作纯文本回复
results.append({"action": "reply", "message": raw})
return results
def _try_parse_json(self, text: str) -> Optional[dict]:
"""容错 JSON 解析"""
text = text.strip()
try:
d = json.loads(text)
if isinstance(d, dict):
return d
except (json.JSONDecodeError, ValueError):
# 尝试提取第一个 JSON 对象
for start_char in ["{", "["]:
if start_char in text:
idx = text.index(start_char)
end_char = "}" if start_char == "{" else "]"
end_idx = text.rfind(end_char)
if end_idx > idx:
try:
d = json.loads(text[idx:end_idx + 1])
if isinstance(d, dict):
return d
except (json.JSONDecodeError, ValueError):
pass
return None
# ═══════════════════════════════════════════════════════
# 工具执行
# ═══════════════════════════════════════════════════════
async def _execute_tool(self, tool_name: str, params: dict) -> str:
"""执行工具,返回结果字符串。
优先级:
1. ToolRegistry 注册的 handler
2. SDLC 内建工具(兼容 cockpit_chat.dspy 的 TOOLS
3. ask_user始终可用
"""
# 1. 注册的 handler
if self._tool_registry:
handler = self._tool_registry.get_handler(tool_name)
if handler:
try:
result = await handler(params, {
"project_id": self.project_id,
"user_id": self.user_id,
"workspace_dir": self.workspace_dir,
})
return str(result)
except Exception as e:
logger.error(f"Tool {tool_name} handler error: {e}")
return f"ERROR: {str(e)[:300]}"
# 2. 产线能力包工具(从 PipelineAbility 注册表按 pipeline_id 取 handler
result = await self._execute_ability_tool(tool_name, params)
if result is not None:
return result
# 3. 通用内建工具core 内核:项目管理/终端/文件/搜索/会话/规划)
result = await self._execute_sdlc_tool(tool_name, params)
if result is not None:
return result
# 4. ask_user
if tool_name == "ask_user":
return f"QUESTION: {params.get('question', '')}"
return f"未知工具: {tool_name}"
def _build_ctx(self) -> dict:
"""构造传给产线能力 handler / slash 命令的上下文。"""
return {
"project_id": self.project_id,
"user_id": self.user_id,
"pipeline_id": self.pipeline_id,
"workspace_dir": self.workspace_dir,
"model_name": self.model_name,
"config": self.config,
}
def _build_slash_ctx(self) -> dict:
"""构造传给 slash 命令 handler 的上下文(含 executor 引用 + role + base_url"""
ctx = self._build_ctx()
ctx["executor"] = self
ctx["role"] = self.role
ctx["base_url"] = getattr(self, "base_url", "") or ""
return ctx
async def _execute_ability_tool(self, tool_name: str, params: dict) -> Optional[str]:
"""产线能力包工具:按 pipeline_id 从 PipelineAbility 注册表取 handler 执行。"""
try:
from pipeline_core import get_ability, DEFAULT_ABILITY_ID
ability = get_ability(self.pipeline_id) or get_ability(DEFAULT_ABILITY_ID)
if not ability or tool_name not in ability.handlers:
return None
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
return await ability.handlers[tool_name](sor, params, self._build_ctx())
except Exception as e:
logger.error(f"ability tool {tool_name} error: {e}")
return f"ERROR: {str(e)[:300]}"
async def _execute_sdlc_tool(self, tool_name: str, params: dict) -> Optional[str]:
"""通用内建工具core 内核,产线无关)。"""
# 导入现有 handler
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
return await self._dispatch_sdlc_tool(sor, tool_name, params)
except Exception as e:
logger.error(f"SDLC tool {tool_name} error: {e}")
return f"ERROR: {str(e)[:300]}"
async def _dispatch_sdlc_tool(self, sor, tool_name: str, params: dict) -> str:
"""SDL 工具分发(兼容 cockpit_chat.dspy 的 16 个工具)"""
p = params or {}
pid = self.project_id
# 通用内建工具映射core 内核,产线无关)。
# 产线专属工具create_task/diagnose_project 等)已迁移到 sdlc_ability 能力包,
# 由 _execute_ability_tool 按 pipeline_id 动态挂载。
handlers = {
"switch_project": self._t_switch_project,
"create_project": self._t_create_project,
"run_command": self._t_run_command,
# ── 通用工具集Hermes CLI 能力子集)──
"read_file": self._t_read_file,
"load_skill": self._t_load_skill,
"list_packs": self._t_list_packs,
"install_pack": self._t_install_pack,
"propose_skill": self._t_propose_skill,
"write_file": self._t_write_file,
"list_files": self._t_list_files,
"search_files": self._t_search_files,
"session_search": self._t_session_search,
"todo": self._t_todo,
"delegate_subtask": self._t_delegate_subtask,
}
handler = handlers.get(tool_name)
if handler:
return await handler(sor, p, pid)
return None
# ── 工具实现 ──
async def _persist_project(self, sor, pid):
"""持久化当前项目。
- 有 session_id写 pipeline_session_settingsweb 多 tab 各自项目上下文,互不覆盖),
同时写全局 pipeline_agent_settings.current_project_id 作为「最近项目」兜底(供无 session 消费者)。
- 无 session_id只写全局向后兼容如微信通道 / run_agent 直连)。
只改 self.project_id 不够——AgentExecutor 每轮新建run 结束即销毁,
下一轮又从持久层读回旧项目。
"""
if not self.user_id or not pid:
return
try:
from appPublic.uniqueID import getID
# 全局「最近项目」兜底
await sor.sqlExe(
"UPDATE pipeline_agent_settings SET current_project_id=${pid}$ "
"WHERE user_id=${uid}$",
{"pid": pid, "uid": self.user_id})
exists = await sor.sqlExe(
"SELECT 1 FROM pipeline_agent_settings WHERE user_id=${uid}$",
{"uid": self.user_id})
if not exists:
await sor.sqlExe(
"INSERT INTO pipeline_agent_settings (id, user_id, current_project_id) "
"VALUES (${id}$, ${uid}$, ${pid}$)",
{"id": getID(), "uid": self.user_id, "pid": pid})
# 会话级项目上下文(多 tab 隔离)
if self.session_id:
try:
await sor.sqlExe(
"UPDATE pipeline_session_settings SET current_project_id=${pid}$ "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"pid": pid, "uid": self.user_id, "sid": self.session_id})
sexists = await sor.sqlExe(
"SELECT 1 FROM pipeline_session_settings "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"uid": self.user_id, "sid": self.session_id})
if not sexists:
await sor.sqlExe(
"INSERT INTO pipeline_session_settings "
"(id, user_id, session_id, current_project_id) "
"VALUES (${id}$, ${uid}$, ${sid}$, ${pid}$)",
{"id": getID(), "uid": self.user_id,
"sid": self.session_id, "pid": pid})
except Exception as e:
logger.warning(f"persist session project failed: {e}")
except Exception as e:
logger.warning(f"persist project failed: {e}")
async def _t_switch_project(self, sor, p, pid):
name = p.get("project_name", "").strip()
if not name:
return "需要项目名称"
# 精确匹配(限定当前项目空间 + 机构)
recs = await sor.sqlExe(
"SELECT id, name FROM sd_projects WHERE name=${n}$ AND pipeline_id=${s}$",
{"n": name, "s": self.space})
if recs:
r = recs[0]
self.project_id = getattr(r, "id", "")
await self._persist_project(sor, self.project_id)
return f"OK: 已切换到 {getattr(r, 'name', name)}"
# LLM 分类匹配
try:
from pipeline_service.llm_bridge import llm_call
all_recs = await sor.sqlExe(
"SELECT name FROM sd_projects WHERE pipeline_id=${s}$ ORDER BY created_at DESC LIMIT 20",
{"s": self.space})
pnames = [getattr(r, "name", "") for r in (all_recs or [])]
classify_prompt = f"用户输入: {name}\n项目列表: {', '.join(pnames)}\n\n判断用户想要哪个项目。只回复项目名或\"不存在\""
matched = await llm_call(classify_prompt, temperature=0.0, org_id=self.org_id)
matched = matched.strip().strip('"').strip("'")
recs2 = await sor.sqlExe(
"SELECT id, name FROM sd_projects WHERE name=${n}$ AND pipeline_id=${s}$",
{"n": matched, "s": self.space})
if recs2:
r = recs2[0]
self.project_id = getattr(r, "id", "")
await self._persist_project(sor, self.project_id)
return f"OK: 已切换到 {getattr(r, 'name', matched)}"
except Exception:
pass
return f"未找到项目: {name}"
async def _t_create_project(self, sor, p, pid):
import os
import re
from appPublic.uniqueID import getID
name = p.get("name", "").strip()
desc = p.get("description", "")
if not name:
return "需要项目名称"
# 查当前用户 org_id工作空间按 org 隔离)
org_id = "0"
if self.user_id:
_u = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$ LIMIT 1", {"u": self.user_id})
if _u:
org_id = getattr(_u[0], "orgid", "0") or "0"
# 项目专属工作空间目录(新结构:{base}/{org}/{space}/projects/{英文slug}/
# 与 apps/、modules/ 平级;工作空间控件按此结构读取)。
# 英文 slug项目名保留 [A-Za-z0-9_-],且须含字母才有辨识度(避免纯数字如 "7"
# 纯中文或纯数字名兑底 proj_{短ID}。
from .workspace import get_workspace_base, build_space_path
workspace_base = await get_workspace_base(sor)
pid_val = getID()
space_dir = build_space_path(workspace_base, org_id, self.space)
slug = re.sub(r'[^A-Za-z0-9_-]', '', name).strip('_-')
if not slug or not re.search(r'[A-Za-z]', slug):
slug = 'proj_' + pid_val[-8:]
project_dir = os.path.join(space_dir, 'projects', slug)
if os.path.isdir(project_dir):
# 同 slug 已存在(不同项目重名)→ 追加短 ID避免共享目录互相覆盖
slug = f"{slug}_{pid_val[-8:]}"
project_dir = os.path.join(space_dir, 'projects', slug)
os.makedirs(project_dir, exist_ok=True)
await sor.C("sd_projects", {
"id": pid_val, "name": name, "description": desc,
"status": "active", "org_id": org_id,
"pipeline_id": self.space, "workspace_dir": project_dir,
"directory_name": slug,
"created_by": self.user_id or "",
"default_model": self.model_name or "",
})
await sor.C("sd_iterations", {
"id": getID(), "project_id": pid_val,
"iteration_name": f"{name}-初始迭代",
"iteration_type": "default", "status": "in_progress", "priority": 1,
"seq_no": 1,
})
self.project_id = pid_val
await self._persist_project(sor, pid_val)
return f"OK: 已创建项目 {name}"
async def _t_run_command(self, sor, p, pid):
cmd = p.get("command", "")
if not cmd:
return "需要命令"
try:
from pipeline_service.agent_loop import _run_shell
r = await _run_shell(cmd, self.workspace_dir, timeout=60)
return f"rc={r['rc']}\n{r['stdout'][:2000]}"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
def _resolve_ws_path(self, path: str) -> str:
"""解析相对路径为工作空间内绝对路径(越界返回 '')。"""
import os
ws = self.workspace_dir or WORKSPACE_BASE
full = os.path.abspath(os.path.join(ws, path or "."))
# 限制在 workspace 内
if full != ws and not full.startswith(ws.rstrip("/") + "/"):
return ""
return full
async def _t_read_file(self, sor, p, pid):
path = p.get("path", "")
if not path:
return "FAIL: 需要文件路径"
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
if not os.path.isfile(full):
return f"FAIL: 文件不存在 {path}"
ext = os.path.splitext(full)[1].lower()
# docx提取 word/document.xml 文本
if ext == '.docx':
import zipfile
import re as _re
with zipfile.ZipFile(full) as z:
xml = z.read('word/document.xml').decode('utf-8', errors='ignore')
texts = _re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)
return ('\n'.join(texts)[:30000]) or '(docx 无文本内容)'
# 纯文本类:直接读
if ext in ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini', ''):
with open(full, encoding="utf-8", errors="ignore") as f:
return f.read()[:30000]
# 其他二进制
return f"该文件是二进制格式({ext or '无扩展名'}),无法直接读取文本。可改用 run_command 处理,或让用户上传文本版本。"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_load_skill(self, sor, p, pid):
name = (p.get("name") or "").strip()
if not name:
return "FAIL: 需要技能名称"
if not self._skill_loader:
return "FAIL: 技能系统未初始化"
merged = self._skill_loader.get_merged(
pipeline_id=self.pipeline_id or "",
role=self.role or "",
project_id=self.project_id or "",
org_id=self.org_id or "",
user_id=self.user_id or "")
if name not in merged:
names = ", ".join(sorted(merged.keys())) or "(无可用技能)"
return f"FAIL: 技能 '{name}' 不存在。可用技能: {names}"
return merged[name].to_prompt_block()
async def _t_list_packs(self, sor, p, pid):
try:
from pipeline_core.skill_pack import list_packs
packs = list_packs()
if not packs:
return "(无可安装的技能集)"
lines = ["可安装的技能集:"]
for pk in packs:
lines.append(f" - {pk['name']}: {pk.get('title', '')}{pk.get('skill_count', 0)} 个技能)")
return "\n".join(lines)
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_install_pack(self, sor, p, pid):
pack = (p.get("pack") or "").strip()
if not pack:
return "FAIL: 需要技能集名(先用 list_packs 查看可用的技能集名)"
try:
from pipeline_core.skill_pack import install_pack
base_dir = self._skill_loader.base_dir if self._skill_loader else ""
org_id = self.org_id or "0"
r = install_pack(pack, base_dir, org_id)
if r.get("success"):
if self._skill_loader:
self._skill_loader.reload()
installed = r.get("installed", [])
return f"OK: 已安装技能集 {pack}{len(installed)} 个技能)"
return f"FAIL: {r.get('error', '未知错误')}"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_propose_skill(self, sor, p, pid):
name = (p.get("name") or "").strip()
description = (p.get("description") or "").strip()
content = (p.get("content") or "").strip()
if not name or not content:
return "FAIL: 需要技能名和内容content 为 SKILL.md 草稿正文)"
try:
from appPublic.uniqueID import getID
await sor.C("skill_proposals", {
"id": getID(),
"name": name,
"description": description,
"content": content,
"source": "agent",
"status": "pending",
"org_id": self.org_id or "",
"pipeline_id": self.pipeline_id or "",
"created_by": self.user_id or "",
})
return f"OK: 已提交技能提议 '{name}'(待审核,暂不生效)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_write_file(self, sor, p, pid):
path = p.get("path", "")
content = p.get("content", "")
if not path:
return "FAIL: 需要文件路径"
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content or "")
return f"OK: 已写入 {path} ({len(content)} 字符)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_list_files(self, sor, p, pid):
path = p.get("path", "") or "."
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
if not os.path.isdir(full):
return f"FAIL: 目录不存在 {path}"
items = sorted(os.listdir(full))[:50]
lines = []
for name in items:
fp = os.path.join(full, name)
if name.startswith("."):
continue
t = "DIR" if os.path.isdir(fp) else "FILE"
size = os.path.getsize(fp) if os.path.isfile(fp) else 0
lines.append(f"[{t}] {name} ({size}B)")
return "\n".join(lines) if lines else "(空目录)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_search_files(self, sor, p, pid):
pattern = p.get("pattern", "")
if not pattern:
return "FAIL: 需要搜索关键词"
path = p.get("path", "") or "."
full = self._resolve_ws_path(path)
if not full:
return f"FAIL: 路径越界 {path}"
try:
from pipeline_service.agent_loop import _run_shell
# grep -rn排除 .git 和 __pycache__限制输出
r = await _run_shell(
f"grep -rn --include='*.py' --include='*.md' --include='*.json' --include='*.txt' "
f"--exclude-dir=.git --exclude-dir=__pycache__ '{pattern}' . 2>/dev/null | head -50",
full, timeout=30)
out = r.get("stdout", "").strip()
return out[:4000] if out else f"未找到匹配 '{pattern}' 的内容"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_session_search(self, sor, p, pid):
query = p.get("query", "").strip()
if not query:
return "FAIL: 需要搜索关键词"
try:
recs = await sor.sqlExe(
"SELECT role, content, created_at FROM pipeline_conversations "
"WHERE created_by=${u}$ AND iteration_id=${pid}$ AND content LIKE ${q}$ "
"ORDER BY created_at DESC LIMIT 10",
{"u": self.user_id, "pid": pid, "q": f"%{query}%"})
if not recs:
return f"未找到包含 '{query}' 的会话记录"
lines = []
for r in recs:
content = (getattr(r, "content", "") or "")[:300]
lines.append(f"[{getattr(r, 'role', '?')}] {content}")
return "\n".join(lines)
except Exception as e:
return f"ERROR: {str(e)[:300]}"
async def _t_todo(self, sor, p, pid):
action = p.get("action", "list")
content = (p.get("content", "") or "").strip()
if action == "add":
if not content:
return "FAIL: add 需要任务内容"
self._todos.append({"done": False, "content": content})
return f"OK: 已添加任务(共 {len(self._todos)} 项)"
if action == "done":
if not content:
return "FAIL: done 需要任务序号或内容"
for t in self._todos:
if content in t["content"] or content == str(self._todos.index(t) + 1):
t["done"] = True
return f"OK: 已完成任务 '{t['content']}'"
return f"未找到任务 '{content}'"
# list默认
if not self._todos:
return "任务清单为空"
lines = []
for i, t in enumerate(self._todos):
mark = "" if t["done"] else ""
lines.append(f"{i + 1}. {mark} {t['content']}")
return "\n".join(lines)
async def _t_delegate_subtask(self, sor, p, pid):
goal = (p.get("goal", "") or "").strip()
context = (p.get("context", "") or "").strip()
if not goal:
return "FAIL: 需要子任务目标"
try:
sub = AgentExecutor(
config=self.config,
project_id=self.project_id,
user_id=self.user_id,
workspace_dir=self.workspace_dir,
model_name=self.model_name,
)
prompt = goal if not context else f"{goal}\n\n背景:{context}"
result_parts = []
async for chunk in sub.run(prompt):
try:
data = json.loads(chunk)
except Exception:
continue
t = data.get("type", "")
if t == "reply":
result_parts.append(data.get("message", ""))
elif t == "tool_result":
result_parts.append(data.get("result", ""))
out = "\n".join(x for x in result_parts if x).strip()
return out[:3000] or "(子任务无输出)"
except Exception as e:
return f"ERROR: {str(e)[:300]}"
# ═══════════════════════════════════════════════════════
# 上下文压缩
# ═══════════════════════════════════════════════════════
async def _maybe_compress(self):
"""检查是否需要压缩上下文"""
est_tokens = self._estimate_tokens()
limit = self.config.context_limit
threshold = int(limit * self.config.compression.threshold)
if est_tokens < threshold:
return
logger.info(f"Compressing: {est_tokens}/{limit} tokens (threshold={threshold})")
# 保留 system + 最近 N 轮
keep = self.config.compression.keep_recent * 2 # user + assistant 各一条
system_msg = self._msgs[0] if self._msgs else None
recent = self._msgs[-keep:] if keep < len(self._msgs) else self._msgs[1:]
# 压缩中间消息
middle = self._msgs[1:-keep] if keep < len(self._msgs) else []
if middle:
summary = await self._summarize(middle)
compressed = [
system_msg,
{"role": "user", "content": f"[对话摘要]\n{summary}"},
] if system_msg else []
compressed.extend(recent)
self._msgs = compressed
def _estimate_tokens(self) -> int:
"""简单 token 估算(中文 ~1.5 字符/token英文 ~4 字符/token"""
total = 0
for m in self._msgs:
content = m.get("content") or ""
# 简单估算:平均每 2 字符 1 token
total += len(content) // 2 + 1
return total
async def _summarize(self, messages: list) -> str:
"""压缩消息为摘要"""
if len(messages) <= 2:
return "\n".join((m.get("content") or "")[:200] for m in messages)
text = "\n".join(
f"[{m['role']}]: {(m.get('content') or '')[:500]}"
for m in messages
)
try:
from pipeline_service.llm_bridge import llm_call
summary = await llm_call(
f"请用3-5句话总结以下对话的关键信息\n\n{text[:4000]}",
temperature=0.1,
org_id=self.org_id,
)
return summary[:500]
except Exception:
return text[:500]
# ═══════════════════════════════════════════════════════
# 会话管理
# ═══════════════════════════════════════════════════════
async def _load_history(self, external_history: List[dict] = None) -> List[dict]:
"""加载历史消息。隔离策略session_id > project 级别。
多 tab 独立会话session_id 非空时按 session_id 隔离(每个 tab 独立历史);
否则退回 config.session_isolationproject/user/none
"""
if external_history:
return external_history
if self.config.session_isolation == "none":
return []
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
# 会话级隔离web 多 tab 独立会话)
if self.session_id:
recs = await sor.sqlExe(
"SELECT role, content FROM pipeline_conversations "
"WHERE session_id=${sid}$ AND created_by=${uid}$ "
"ORDER BY created_at ASC LIMIT ${lim}$",
{"sid": self.session_id, "uid": self.user_id or "",
"lim": self.config.history_limit},
)
else:
# 项目隔离
pid = self.project_id
if self.config.session_isolation == "project" and pid:
recs = await sor.sqlExe(
"SELECT role, content FROM pipeline_conversations "
"WHERE iteration_id=${pid}$ AND created_by=${uid}$ "
"ORDER BY created_at ASC LIMIT ${lim}$",
{"pid": pid, "uid": self.user_id or "", "lim": self.config.history_limit},
)
else:
recs = await sor.sqlExe(
"SELECT role, content FROM pipeline_conversations "
"WHERE created_by=${uid}$ "
"ORDER BY created_at ASC LIMIT ${lim}$",
{"uid": self.user_id or "", "lim": self.config.history_limit},
)
if not recs:
return []
# 过滤 tool_call JSON 污染(关键!)
msgs = []
for r in recs:
role = getattr(r, "role", "")
content = getattr(r, "content", "") or ""
# 跳过 tool_call JSON
if content.startswith('{"action":"tool_call"'):
continue
if content.startswith("已调用 "):
continue
if role in ("user", "assistant"):
msgs.append({"role": role, "content": content})
return msgs
except Exception as e:
logger.warning(f"History load failed: {e}")
return []
async def _save_turn(self, user_input: str, reply: str):
"""持久化本轮对话"""
if self.config.session_isolation == "none":
return
try:
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
# 用户消息
await sor.C("pipeline_conversations", {
"id": getID(),
"role": "user",
"content": user_input,
"created_by": self.user_id or "",
"iteration_id": self.project_id or "",
"session_id": self.session_id or "",
})
# 助手回复(不含 tool_call JSON
reply_clean = reply
if reply.startswith('{"action":"tool_call"'):
reply_clean = "[工具调用]"
await sor.C("pipeline_conversations", {
"id": getID(),
"role": "assistant",
"content": reply_clean[:4000],
"created_by": self.user_id or "",
"iteration_id": self.project_id or "",
"session_id": self.session_id or "",
})
except Exception as e:
logger.warning(f"Session save failed: {e}")
async def _load_project_context(self) -> str:
"""加载项目上下文(项目名、任务数等)"""
if not self.project_id:
return ""
try:
from sqlor.dbpools import DBPools
db = DBPools()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT name, description FROM sd_projects WHERE id=${pid}$",
{"pid": self.project_id})
if recs:
name = getattr(recs[0], "name", "")
return f"项目: {name}"
except Exception:
pass
return ""
def _is_dangerous_command(self, cmd: str) -> bool:
"""判断 shell 命令是否危险run_command 只对危险命令要求确认)。"""
c = (cmd or "").strip().lower()
dangerous = (
"rm -rf", "rm -r", "sudo", "su ", "drop table", "drop database",
"truncate", "delete from", "mkfs", "dd if", ":(){", "shutdown",
"reboot", "kill -9", "chmod 777", "chown", "> /dev/",
)
return any(d in c for d in dangerous)
def _detect_confirm_decision(self, user_input: str) -> str:
"""检测用户对危险命令确认的回复。返回 approve / approve_all / cancel / ''"""
t = (user_input or "").strip().lower()
if not t:
return ""
for kw in ("全部确认", "确认全部", "全部同意", "都确认", "都同意", "一律确认", "approve all", "yes to all", "always"):
if kw in t:
return "approve_all"
for kw in ("取消", "放弃", "不执行", "别执行", "cancel", "abort", "不要"):
if kw in t:
return "cancel"
for kw in ("确认", "同意", "执行", "可以", "approve", "confirm", "yes", "ok", "继续"):
if kw in t:
return "approve"
return ""
def _needs_confirmation(self, tool_name: str, params: dict = None) -> bool:
"""检查工具是否需要用户确认。
会话级「全部确认」approve_all时直接放行否则 run_command 只对
危险命令确认,普通命令自动执行,其他 requires_confirmation 工具一律确认。
"""
if self._session and getattr(self._session, "approve_all", False):
return False
if self._tool_registry:
tool = self._tool_registry.get(tool_name)
if tool and tool.requires_confirmation:
if tool_name == "run_command":
cmd = (params or {}).get("command", "")
return self._is_dangerous_command(cmd)
return True
return False
# ═══════════════════════════════════════════════════════════
# 便捷函数
# ═══════════════════════════════════════════════════════════
async def run_agent(
user_input: str,
project_id: str = "",
user_id: str = "",
config=None,
model_name: str = None,
workspace_dir: str = "",
) -> AsyncGenerator[str, None]:
"""便捷入口:运行 agent 并流式输出。
用法:
async for chunk in run_agent("创建一个新项目"):
print(chunk)
"""
if config is None:
try:
from pipeline_core.agent_config import load_agent_config, SDLC_DEFAULT_CONFIG
config = await load_agent_config(project_id=project_id)
except ImportError:
config = SDLC_DEFAULT_CONFIG
executor = AgentExecutor(
config=config,
project_id=project_id,
user_id=user_id,
workspace_dir=workspace_dir,
model_name=model_name,
)
async for chunk in executor.run(user_input):
yield chunk