Compare commits

...

11 Commits

Author SHA1 Message Date
51e38e039b chore: update gitignore and remove env.example
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:15 +08:00
e637c8ca2f feat(frontend): update chat composables and vite config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:13 +08:00
52fb619084 test(backend): add tests for orchestration and learning runtimes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:12 +08:00
dc9051debc feat(routers): add API endpoints for agents and skills
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:10 +08:00
74fdfc2652 feat(services): enhance services with rollback and observability
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:08 +08:00
36c93a764f feat(learning): add learning runtime with pattern mining
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:07 +08:00
72a60c698a feat(skills): enhance skills system with matching and evaluation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:16:04 +08:00
4ef7549efe feat(orchestration): add orchestration system with task scheduling
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:11:17 +08:00
de08165e07 feat(agents): enhance agent core with state management
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:10:58 +08:00
4702cc8ed2 feat(database): add schema bootstrap and config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-08 00:10:42 +08:00
62bf414ff2 fix(frontend): weather shows default value when API fails
- Set default weather (Clear 25°C, Beijing) on mount before API call
- Don't overwrite weather on API failure to keep default visible
- Use Beijing coordinates as default fallback location
2026-04-07 13:49:36 +08:00
66 changed files with 5200 additions and 134 deletions

3
.gitignore vendored
View File

@@ -40,6 +40,9 @@ logs/
.claude/ .claude/
.worktrees/ .worktrees/
# Demo (excluded from version control)
demo/
# Lock files (use in development, commit in production) # Lock files (use in development, commit in production)
# uv.lock - uncomment if you want to commit lock file # uv.lock - uncomment if you want to commit lock file
# package-lock.json - uncomment if you want to commit lock file # package-lock.json - uncomment if you want to commit lock file

View File

@@ -27,6 +27,9 @@ from app.agents.prompts import (
MASTER_SYSTEM_PROMPT, MASTER_SYSTEM_PROMPT,
SCHEDULE_PLANNER_SYSTEM_PROMPT, SCHEDULE_PLANNER_SYSTEM_PROMPT,
) )
from app.agents.orchestration.result_merge import merge_task_results
from app.agents.orchestration.scheduler import build_subtask_specs, ensure_child_links
from app.agents.orchestration.subagent_runtime import subtask_spec_to_agent_task
from app.agents.registry import load_builtin_registry_indexes from app.agents.registry import load_builtin_registry_indexes
from app.agents.runtime_metrics import ( from app.agents.runtime_metrics import (
coerce_cost_thresholds, coerce_cost_thresholds,
@@ -36,6 +39,14 @@ from app.agents.runtime_metrics import (
) )
from app.agents.schemas.event import AgentEvent from app.agents.schemas.event import AgentEvent
from app.agents.schemas.message import AgentMessage from app.agents.schemas.message import AgentMessage
from app.agents.schemas.orchestration import (
ExecutionDecision,
MergeReport,
RuntimeRequestContext,
TaskGraph,
VerificationReport,
render_runtime_request_context_summary,
)
from app.agents.schemas.task import ( from app.agents.schemas.task import (
AgentTask, AgentTask,
CollaborationBudget, CollaborationBudget,
@@ -44,6 +55,7 @@ from app.agents.schemas.task import (
TaskResult, TaskResult,
) )
from app.agents.skill_registry import build_skill_context from app.agents.skill_registry import build_skill_context
from app.agents.skills.retriever import build_shortlisted_skill_context
from app.agents.state import AgentRole, AgentState from app.agents.state import AgentRole, AgentState
from app.agents.tools import SUB_COMMANDER_TOOLSETS from app.agents.tools import SUB_COMMANDER_TOOLSETS
from app.agents.tools.time_reasoning import normalize_tool_time_arguments from app.agents.tools.time_reasoning import normalize_tool_time_arguments
@@ -1148,6 +1160,57 @@ def _parse_json_action(content: str, allowed_tools: list[str]) -> dict[str, Any]
return None return None
def _looks_like_internal_tool_markup(content: str) -> bool:
text = (content or "").strip()
if not text:
return False
lowered = text.lower()
xml_markers = (
"<minimax:tool_call",
"</minimax:tool_call>",
"<invoke name=",
"</invoke>",
"<parameter name=",
"</parameter>",
)
if any(marker in lowered for marker in xml_markers):
return True
return "分发说明" in text and ("<invoke name=" in lowered or "tool_call" in lowered)
def _clean_tool_result_for_user(tool_result: str | None) -> str:
text = (tool_result or "").strip()
if not text:
return ""
cleaned_lines = [
re.sub(r"^\[[^\]]+\]\s*", "", line).strip()
for line in text.splitlines()
if line.strip()
]
return "\n".join(line for line in cleaned_lines if line).strip()
def _build_internal_markup_fallback_response(
state: AgentState,
*,
sub_commander: str,
) -> str | None:
tool_result = state.get("last_tool_result")
cleaned_tool_result = _clean_tool_result_for_user(tool_result)
if not cleaned_tool_result or _tool_result_indicates_failure(cleaned_tool_result):
return None
if sub_commander == "librarian_retrieval":
if _is_missing_knowledge_result(tool_result):
return "这次检索没有拿到有效证据。我先不展示内部调度过程;如果您愿意,我可以直接基于常识回答,或改为联网搜索后再整理。"
return f"我已经完成检索,直接给您可用信息:\n\n{cleaned_tool_result}"
return cleaned_tool_result
def _has_active_structured_continuation(state: AgentState) -> bool: def _has_active_structured_continuation(state: AgentState) -> bool:
pending_action = state.get("pending_action") or {} pending_action = state.get("pending_action") or {}
routing_decision = state.get("routing_decision") or {} routing_decision = state.get("routing_decision") or {}
@@ -1205,6 +1268,70 @@ def _build_structured_continuity_summary(state: AgentState) -> str | None:
return "\n".join(lines) return "\n".join(lines)
def _build_retrospective_context_summary(state: AgentState) -> str | None:
retrospectives = list(state.get("recalled_retrospectives") or [])
if not retrospectives:
return None
lines = ["【相关历史复盘】"]
for item in retrospectives[:2]:
if not isinstance(item, dict):
continue
request_summary = str(item.get("request_summary") or item.get("task_type") or "").strip()
execution_mode = str(item.get("execution_mode") or "").strip()
success_score = float(item.get("success_score") or 0.0)
reusable_patterns = list(item.get("reusable_patterns") or [])
avoid_patterns = list(item.get("avoid_patterns") or [])
summary_parts = [request_summary[:80] or execution_mode or "历史任务"]
if execution_mode:
summary_parts.append(f"mode={execution_mode}")
summary_parts.append(f"score={success_score:.2f}")
if reusable_patterns:
summary_parts.append(f"可复用={','.join(reusable_patterns[:2])}")
elif avoid_patterns:
summary_parts.append(f"避坑={','.join(avoid_patterns[:2])}")
lines.append(f"- {''.join(summary_parts)}")
return "\n".join(lines) if len(lines) > 1 else None
def _estimate_request_complexity(user_query: str, selected_roles: list[str]) -> float:
text = (user_query or "").strip()
base = min(len(text) / 120.0, 1.0)
role_boost = min(len(selected_roles) * 0.2, 0.6)
return round(min(base + role_boost, 1.0), 2)
def _record_execution_decision(
state: AgentState,
*,
user_query: str,
mode: Literal["direct", "collaboration", "parallel"],
reason: str,
selected_roles: list[str] | None = None,
parallel_worthiness_score: float | None = None,
) -> None:
runtime_request_context = state.get("runtime_request_context") or {}
request_id = str(runtime_request_context.get("request_id") or state.get("conversation_id") or "")
roles = list(selected_roles or [])
decision = ExecutionDecision(
request_id=request_id or f"request-{uuid4().hex[:8]}",
mode=mode,
reason=reason,
complexity_score=_estimate_request_complexity(user_query, roles),
parallel_worthiness_score=parallel_worthiness_score,
selected_roles=roles,
)
state["execution_decision"] = decision.model_dump(mode="json")
_append_event_trace(
state,
"agent.execution.decided",
payload=state["execution_decision"],
)
def _build_system_messages( def _build_system_messages(
state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str
) -> list[BaseMessage]: ) -> list[BaseMessage]:
@@ -1214,6 +1341,19 @@ def _build_system_messages(
if current_datetime_context: if current_datetime_context:
messages.append(SystemMessage(content=current_datetime_context)) messages.append(SystemMessage(content=current_datetime_context))
runtime_request_context = state.get("runtime_request_context")
if isinstance(runtime_request_context, dict) and runtime_request_context:
try:
runtime_context_model = RuntimeRequestContext.model_validate(runtime_request_context)
except Exception:
runtime_context_model = None
if runtime_context_model is not None:
messages.append(
SystemMessage(
content=render_runtime_request_context_summary(runtime_context_model)
)
)
continuity_summary = _build_structured_continuity_summary(state) continuity_summary = _build_structured_continuity_summary(state)
if continuity_summary: if continuity_summary:
messages.append(SystemMessage(content=continuity_summary)) messages.append(SystemMessage(content=continuity_summary))
@@ -1226,6 +1366,10 @@ def _build_system_messages(
if collaboration_summary: if collaboration_summary:
messages.append(SystemMessage(content=collaboration_summary)) messages.append(SystemMessage(content=collaboration_summary))
retrospective_summary = _build_retrospective_context_summary(state)
if retrospective_summary:
messages.append(SystemMessage(content=retrospective_summary))
role_context_map = { role_context_map = {
AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"), AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"),
AgentRole.LIBRARIAN: state.get("knowledge_context"), AgentRole.LIBRARIAN: state.get("knowledge_context"),
@@ -1237,7 +1381,11 @@ def _build_system_messages(
role_skill_key = ROLE_SKILL_CONTEXT.get(role) role_skill_key = ROLE_SKILL_CONTEXT.get(role)
if role_skill_key: if role_skill_key:
skill_context = build_skill_context(role_skill_key) shortlisted_context = build_shortlisted_skill_context(
state.get("skill_shortlist"),
agent_type=role_skill_key,
)
skill_context = shortlisted_context or build_skill_context(role_skill_key)
if skill_context: if skill_context:
messages.append(SystemMessage(content=skill_context)) messages.append(SystemMessage(content=skill_context))
@@ -1322,6 +1470,29 @@ def _build_collaboration_tasks(user_query: str) -> list[AgentTask]:
return tasks return tasks
def _build_collaboration_plan_from_task_graph(
state: AgentState,
user_query: str,
) -> tuple[list[AgentTask], list[dict[str, Any]]]:
raw_task_graph = state.get("task_graph")
if not isinstance(raw_task_graph, dict) or not raw_task_graph.get("nodes"):
return _build_collaboration_tasks(user_query), []
task_graph = TaskGraph.model_validate(raw_task_graph)
specs = [
spec
for spec in build_subtask_specs(task_graph, query_text=user_query)
if spec.role != "master"
]
child_links = ensure_child_links(specs)
tasks: list[AgentTask] = []
for spec in specs:
task = subtask_spec_to_agent_task(spec)
task.child_task_ids = child_links.get(spec.subtask_id, [])
tasks.append(task)
return tasks, [spec.model_dump(mode="json") for spec in specs]
def _build_collaboration_context_summary(state: AgentState) -> str | None: def _build_collaboration_context_summary(state: AgentState) -> str | None:
if state.get("execution_mode") != "collaboration": if state.get("execution_mode") != "collaboration":
return None return None
@@ -2076,7 +2247,36 @@ async def _run_sub_commander(
) )
_record_response_usage(state, response) _record_response_usage(state, response)
response_text = _stringify_message_content(response.content) response_text = _stringify_message_content(response.content)
response_text_stripped = response_text.strip()
parsed = _parse_json_action(response_text, allowed_tools) parsed = _parse_json_action(response_text, allowed_tools)
if parsed is None and response_text_stripped and _looks_like_internal_tool_markup(
response_text_stripped
):
if int(state.get("retry_count") or 0) >= int(state.get("max_retries") or 0):
state["fallback_parse_error"] = "internal_tool_markup"
state["final_response"] = _build_internal_markup_fallback_response(
state,
sub_commander=sub_commander,
) or (
"这次内部调度没有正确收束成最终答复。我先不展示内部调用过程;您重试一次,我会直接用自然语言回答。"
)
break
if not _guard_sub_commander_budget(
state, "iteration_count", "max_iterations", "max_iterations_exceeded"
):
parsed = None
break
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
state["retry_count"] = int(state.get("retry_count") or 0) + 1
retry_instruction = SystemMessage(
content=(
"上一轮输出了内部调度或工具调用标记,这是协议错误。"
"不要再输出分发说明、XML 标签、<invoke>、<parameter>、JSON 或 tool_call。"
"请直接面向用户给出最终自然语言答复;如果已有工具结果,就基于结果整理;"
"如果工具没有找到证据,可以基于常识直接回答。"
)
)
continue
if parsed is None and response_text.strip() and state.get("tool_round_count"): if parsed is None and response_text.strip() and state.get("tool_round_count"):
state["fallback_parse_error"] = None state["fallback_parse_error"] = None
state["final_response"] = response_text.strip() state["final_response"] = response_text.strip()
@@ -2382,6 +2582,35 @@ def _build_collaboration_final_response(task_results: list[TaskResult | dict[str
return "\n".join(lines) return "\n".join(lines)
def _build_serial_fallback_response(
user_query: str,
task_results: list[TaskResult | dict[str, Any]],
merge_report: MergeReport | dict[str, Any] | None,
) -> str:
normalized_results = [normalize_task_result(item) for item in task_results]
completed = [item for item in normalized_results if item.status == "completed"]
merge_payload = (
merge_report.model_dump(mode="json")
if isinstance(merge_report, MergeReport)
else dict(merge_report or {})
)
lines = [
"并行/协作结果出现冲突或失败,我已切回保守收敛路径。",
f"原始请求:{user_query}",
]
if completed:
lines.append("当前仍可确认的结果:")
for item in completed[:3]:
lines.append(f"- [{item.owner_agent_id or 'unknown'}] {item.summary or '已完成'}")
if merge_payload.get("conflict_flags"):
lines.append("冲突/回退原因:")
for flag in list(merge_payload.get("conflict_flags") or [])[:3]:
lines.append(f"- {flag}")
if not completed:
lines.append("目前没有足够稳定的子任务结果,建议改走 direct 或更小范围的 collaboration。")
return "\n".join(lines)
def _verify_collaboration_results( def _verify_collaboration_results(
state: AgentState, state: AgentState,
tasks: list[AgentTask], tasks: list[AgentTask],
@@ -2411,6 +2640,14 @@ def _verify_collaboration_results(
} }
for item in normalized_results for item in normalized_results
] ]
merge_report = merge_task_results([item.model_dump(mode="json") for item in normalized_results])
state["merge_report"] = merge_report.model_dump(mode="json")
_append_event_trace(
state,
"agent.merge.completed",
payload=state["merge_report"],
)
if missing_task_ids: if missing_task_ids:
summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}" summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}"
verdict = verify_task_result( verdict = verify_task_result(
@@ -2426,20 +2663,51 @@ def _verify_collaboration_results(
verdict = verify_task_result( verdict = verify_task_result(
status="failed", summary=summary, evidence=verification_evidence status="failed", summary=summary, evidence=verification_evidence
) )
elif merge_report.status == "conflicted":
verdict = verify_task_result(
status="failed",
summary=merge_report.summary,
evidence=[
*verification_evidence,
{"type": "merge_conflict", "conflict_flags": merge_report.conflict_flags},
],
)
elif merge_report.status == "fallback":
verdict = verify_task_result(
status="failed",
summary=merge_report.summary,
evidence=[
*verification_evidence,
{"type": "merge_fallback", "conflict_flags": merge_report.conflict_flags},
],
)
else: else:
summary = f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。" summary = (
merge_report.summary
or f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
)
verdict = verify_task_result( verdict = verify_task_result(
status="passed", summary=summary, evidence=verification_evidence status="passed", summary=summary, evidence=verification_evidence
) )
updated_state = apply_verification_verdict(state, verdict) updated_state = apply_verification_verdict(state, verdict)
state.update(updated_state) state.update(updated_state)
state["verification_report"] = VerificationReport(
status=state.get("verification_status") or "skipped",
summary=state.get("verification_summary"),
evidence=list(state.get("verification_evidence") or []),
).model_dump(mode="json")
_append_event_trace(
state,
"agent.verify.completed",
payload=state["verification_report"],
)
async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState: async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState:
_set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started") _set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started")
_record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started") _record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started")
tasks = _build_collaboration_tasks(user_query) tasks, scheduled_subtasks = _build_collaboration_plan_from_task_graph(state, user_query)
if len(tasks) < 2: if len(tasks) < 2:
state["execution_mode"] = "direct" state["execution_mode"] = "direct"
state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"} state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"}
@@ -2487,6 +2755,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
"agent.collaboration.budget.updated", "agent.collaboration.budget.updated",
payload=budget_snapshot, payload=budget_snapshot,
) )
state["scheduled_subtasks"] = scheduled_subtasks
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks] state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
_record_checkpoint( _record_checkpoint(
state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)} state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)}
@@ -2500,6 +2769,21 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
_set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch") _set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch")
for task in tasks: for task in tasks:
scheduled_subtask = next(
(item for item in scheduled_subtasks if item.get("subtask_id") == task.task_id),
None,
)
if scheduled_subtask is not None:
_append_event_trace(
state,
"agent.subtask.started",
payload={
"subtask_id": scheduled_subtask.get("subtask_id"),
"role": scheduled_subtask.get("role"),
"dependencies": scheduled_subtask.get("dependencies") or [],
},
task_id=task.task_id,
)
_record_checkpoint( _record_checkpoint(
state, state,
"collaboration.task_dispatch", "collaboration.task_dispatch",
@@ -2583,6 +2867,17 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
child_task_id=(task.child_task_ids or [None])[0], child_task_id=(task.child_task_ids or [None])[0],
message_id=str(state.get("last_message_id") or "") or None, message_id=str(state.get("last_message_id") or "") or None,
) )
if scheduled_subtask is not None:
_append_event_trace(
state,
"agent.subtask.completed",
payload={
"subtask_id": scheduled_subtask.get("subtask_id"),
"status": task_result.status,
"summary": task_result.summary,
},
task_id=task.task_id,
)
_apply_task_result_to_state(state, task, task_result) _apply_task_result_to_state(state, task, task_result)
if task_result.status != "completed": if task_result.status != "completed":
@@ -2618,6 +2913,26 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
}, },
severity="error" if state.get("verification_status") == "failed" else "info", severity="error" if state.get("verification_status") == "failed" else "info",
) )
merge_report = state.get("merge_report") or {}
if state.get("verification_status") == "failed" and merge_report.get("fallback_used"):
state["final_response"] = _build_serial_fallback_response(
user_query,
state.get("task_results") or [],
merge_report,
)
state["routing_decision"] = {
"mode": "direct",
"reason": "fallback_to_serial_recovery",
}
_append_event_trace(
state,
"agent.rollback.triggered",
payload={
"layer": "collaboration_runtime",
"reason": "merge_fallback_used",
},
severity="warning",
)
_record_checkpoint( _record_checkpoint(
state, state,
"collaboration.completed", "collaboration.completed",
@@ -2679,23 +2994,99 @@ async def master_node(state: AgentState) -> AgentState:
return state return state
state["current_agent"] = _normalize_current_agent(state.get("current_agent")) state["current_agent"] = _normalize_current_agent(state.get("current_agent"))
parallel_worthiness = state.get("parallel_worthiness")
if not isinstance(parallel_worthiness, dict):
runtime_request_context = state.get("runtime_request_context") or {}
if isinstance(runtime_request_context, dict):
candidate_parallel = runtime_request_context.get("parallel_worthiness")
if isinstance(candidate_parallel, dict):
parallel_worthiness = candidate_parallel
if isinstance(parallel_worthiness, dict) and parallel_worthiness:
_append_event_trace(
state,
"agent.parallel.assessed",
payload=parallel_worthiness,
)
skill_shortlist = list(state.get("skill_shortlist") or [])
if skill_shortlist:
_append_event_trace(
state,
"agent.skill.shortlisted",
payload={
"count": len(skill_shortlist),
"skills": [str(item.get("skill_name") or "") for item in skill_shortlist[:4]],
},
)
task_graph = state.get("task_graph")
if isinstance(task_graph, dict) and task_graph.get("nodes"):
_append_event_trace(
state,
"agent.task_graph.built",
payload={
"graph_id": task_graph.get("graph_id"),
"node_count": len(task_graph.get("nodes") or []),
"entry_node_ids": task_graph.get("entry_node_ids") or [],
"max_parallelism": task_graph.get("max_parallelism"),
},
)
elif (
isinstance(parallel_worthiness, dict)
and parallel_worthiness.get("preferred_mode") in {"collaboration", "parallel"}
and not (state.get("feature_flags") or {}).get("ENABLE_PARALLEL_TASK_GRAPH", True)
):
_append_event_trace(
state,
"agent.rollback.triggered",
payload={
"layer": "parallel_task_graph",
"reason": "feature_flag_disabled",
},
)
structured_continuity_route = _route_from_structured_continuity(state, user_query) structured_continuity_route = _route_from_structured_continuity(state, user_query)
clarification_route = _route_from_clarification_context(state, user_query) clarification_route = _route_from_clarification_context(state, user_query)
if structured_continuity_route is not None: if structured_continuity_route is not None:
state["execution_mode"] = "direct" state["execution_mode"] = "direct"
routed_agent = structured_continuity_route routed_agent = structured_continuity_route
_record_execution_decision(
state,
user_query=user_query,
mode="direct",
reason="continue_pending_action",
selected_roles=[routed_agent.value],
)
elif clarification_route is not None: elif clarification_route is not None:
state["execution_mode"] = "direct" state["execution_mode"] = "direct"
routed_agent = clarification_route routed_agent = clarification_route
_record_execution_decision(
state,
user_query=user_query,
mode="direct",
reason="clarification_follow_up",
selected_roles=[routed_agent.value],
)
elif _is_short_confirmation(user_query) and _previous_turn_proposed_schedule_creation( elif _is_short_confirmation(user_query) and _previous_turn_proposed_schedule_creation(
state.get("messages", []) state.get("messages", [])
): ):
state["execution_mode"] = "direct" state["execution_mode"] = "direct"
routed_agent = AgentRole.SCHEDULE_PLANNER routed_agent = AgentRole.SCHEDULE_PLANNER
_record_execution_decision(
state,
user_query=user_query,
mode="direct",
reason="schedule_confirmation_follow_up",
selected_roles=[routed_agent.value],
)
else: else:
request_mode, routing_metadata = _select_request_mode(user_query) request_mode, routing_metadata = _select_request_mode(user_query)
state["routing_decision"] = routing_metadata state["routing_decision"] = routing_metadata
_record_execution_decision(
state,
user_query=user_query,
mode=request_mode,
reason=str(routing_metadata.get("reason") or request_mode),
selected_roles=list(routing_metadata.get("roles") or []),
)
if request_mode == "collaboration": if request_mode == "collaboration":
collaboration_state = await _run_collaboration_flow(state, user_query) collaboration_state = await _run_collaboration_flow(state, user_query)
if collaboration_state.get( if collaboration_state.get(

View File

@@ -0,0 +1,19 @@
from app.agents.learning.jobs import persist_retrospective, schedule_retrospective_job
from app.agents.learning.pattern_miner import LearningPatternMiner
from app.agents.learning.retrospector import build_session_retrospective
from app.agents.learning.session_search import SessionRetrospectiveSearch
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
__all__ = [
"build_session_retrospective",
"LearningArtifactStore",
"LearningPatternMiner",
"persist_retrospective",
"RetrospectiveSignalExtractor",
"schedule_retrospective_job",
"SessionRetrospectiveSearch",
"SessionRetrospectiveStore",
"SkillCandidateBuilder",
]

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
def build_learning_audit_entry(retrospective: SessionRetrospective) -> dict[str, object]:
decision = retrospective.learning_decision
return {
"retrospective_id": retrospective.retrospective_id,
"decision": decision.decision if isinstance(decision, LearningDecision) else None,
"explanation": decision.explanation if isinstance(decision, LearningDecision) else None,
"signal_count": len(retrospective.learning_signals),
"pattern_count": len(retrospective.pattern_candidates),
"skill_candidate_count": len(retrospective.skill_candidates),
"outcome": retrospective.outcome,
}

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from app.agents.schemas.learning import LearningDecision, LearningSignal
def route_learning_signal(signal: LearningSignal) -> str:
if signal.signal_type == "preference":
return "memory"
if signal.signal_type in {"workflow", "decomposition", "tool_success"}:
return "skill"
if signal.signal_type == "correction":
return "audit"
return "memory"
def build_learning_bridge_summary(signals: list[LearningSignal]) -> dict[str, object]:
memory_count = 0
skill_count = 0
audit_count = 0
for signal in signals:
route = route_learning_signal(signal)
if route == "memory":
memory_count += 1
elif route == "skill":
skill_count += 1
else:
audit_count += 1
return {
"memory_signal_count": memory_count,
"skill_signal_count": skill_count,
"audit_signal_count": audit_count,
}
def update_learning_decision_with_bridge(
decision: LearningDecision,
signals: list[LearningSignal],
) -> LearningDecision:
bridge_summary = build_learning_bridge_summary(signals)
metadata = dict(decision.metadata or {})
metadata["bridge"] = bridge_summary
decision.metadata = metadata
return decision

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.config import settings
from app.database import async_session
from app.agents.learning.bridge import update_learning_decision_with_bridge
from app.agents.learning.pattern_miner import LearningPatternMiner
from app.agents.learning.audit import build_learning_audit_entry
from app.agents.learning.retrospector import build_session_retrospective
from app.agents.learning.signal_extractor import RetrospectiveSignalExtractor
from app.agents.learning.skill_candidate_builder import SkillCandidateBuilder
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
from app.agents.schemas.learning import LearningDecision, SessionRetrospective
from app.agents.skills.evaluator import SkillPromotionEvaluator
logger = logging.getLogger(__name__)
def _enrich_retrospective(retrospective: SessionRetrospective) -> SessionRetrospective:
signals = RetrospectiveSignalExtractor().extract(retrospective)
patterns = LearningPatternMiner().mine(signals)
skill_candidates = SkillCandidateBuilder().build(patterns)
decision = LearningDecision(
decision="create_candidate" if skill_candidates else ("reinforce_memory" if signals else "defer"),
explanation=(
"Retrospective produced reusable candidate skills."
if skill_candidates
else "Retrospective only reinforces memory-like evidence."
if signals
else "No stable signal was extracted from this retrospective."
),
evidence_refs=(skill_candidates[0].evidence_refs if skill_candidates else retrospective.evidence_refs[:3]),
metadata={
"signal_count": len(signals),
"pattern_count": len(patterns),
"skill_candidate_count": len(skill_candidates),
},
)
retrospective.learning_signals = signals
retrospective.pattern_candidates = patterns
retrospective.skill_candidates = skill_candidates
retrospective.learning_decision = update_learning_decision_with_bridge(decision, signals)
return retrospective
def _build_learning_artifacts(retrospective: SessionRetrospective) -> list[dict[str, object]]:
artifacts: list[dict[str, object]] = []
for signal in retrospective.learning_signals:
artifacts.append(
{
"artifact_type": "signal",
"artifact_key": signal.signal_type,
"summary_text": signal.explanation or signal.signal_type,
"payload": signal.model_dump(mode="json"),
}
)
for pattern in retrospective.pattern_candidates:
artifacts.append(
{
"artifact_type": "pattern_candidate",
"artifact_key": pattern.pattern_type,
"summary_text": pattern.description,
"payload": pattern.model_dump(mode="json"),
}
)
for candidate in retrospective.skill_candidates:
artifacts.append(
{
"artifact_type": "skill_candidate",
"artifact_key": candidate.name,
"summary_text": candidate.summary,
"payload": candidate.model_dump(mode="json"),
}
)
if retrospective.learning_decision is not None:
artifacts.append(
{
"artifact_type": "learning_decision",
"artifact_key": retrospective.learning_decision.decision,
"summary_text": retrospective.learning_decision.explanation,
"payload": retrospective.learning_decision.model_dump(mode="json"),
}
)
artifacts.append(
{
"artifact_type": "learning_audit",
"artifact_key": retrospective.retrospective_id or "retrospective",
"summary_text": retrospective.learning_decision.explanation,
"payload": build_learning_audit_entry(retrospective),
}
)
return artifacts
def _build_lifecycle_artifacts(decisions: list) -> list[dict[str, object]]:
artifacts: list[dict[str, object]] = []
for decision in decisions:
artifacts.append(
{
"artifact_type": "skill_lifecycle_decision",
"artifact_key": getattr(decision, "skill_name", None) or "skill",
"summary_text": getattr(decision, "reason", ""),
"payload": decision.model_dump(mode="json"),
}
)
return artifacts
async def persist_retrospective(
*,
user_id: str,
conversation_id: str,
request_message_id: str | None,
response_message_id: str | None,
query_text: str,
final_response: str | None,
state: dict[str, Any] | None,
) -> None:
retrospective = build_session_retrospective(
request_id=response_message_id or request_message_id or conversation_id,
session_id=conversation_id,
user_query=query_text,
state=state,
runtime_context={"user_id": user_id},
)
retrospective = _enrich_retrospective(retrospective)
async with async_session() as session:
saved = await SessionRetrospectiveStore(session).save(retrospective)
lifecycle_decisions = []
if settings.ENABLE_SKILL_PROMOTION:
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
user_id=user_id,
retrospective=retrospective,
)
if settings.ENABLE_LEARNING_SIGNALS:
await LearningArtifactStore(session).save_batch(
user_id=user_id,
conversation_id=conversation_id,
retrospective_id=saved.id,
artifacts=[
*_build_learning_artifacts(retrospective),
*_build_lifecycle_artifacts(lifecycle_decisions),
],
)
def schedule_retrospective_job(**kwargs) -> asyncio.Task[None] | None:
if not settings.ENABLE_RETROSPECTIVE:
return None
try:
task = asyncio.create_task(persist_retrospective(**kwargs))
except RuntimeError:
return None
def _handle_completion(done_task: asyncio.Task[None]) -> None:
try:
done_task.result()
except Exception:
logger.exception("retrospective_job_failed")
task.add_done_callback(_handle_completion)
return task
def schedule_retrospective_learning_event(
*,
user_id: str,
conversation_id: str,
retrospective: SessionRetrospective,
session_factory=async_session,
) -> asyncio.Task[None] | None:
if not settings.ENABLE_RETROSPECTIVE:
return None
async def _persist_existing() -> None:
async with session_factory() as session:
enriched = _enrich_retrospective(retrospective)
saved = await SessionRetrospectiveStore(session).save(enriched)
lifecycle_decisions = []
if settings.ENABLE_SKILL_PROMOTION:
lifecycle_decisions = await SkillPromotionEvaluator(session).sync_retrospective(
user_id=user_id,
retrospective=enriched,
)
if settings.ENABLE_LEARNING_SIGNALS:
await LearningArtifactStore(session).save_batch(
user_id=user_id,
conversation_id=conversation_id,
retrospective_id=saved.id,
artifacts=[
*_build_learning_artifacts(enriched),
*_build_lifecycle_artifacts(lifecycle_decisions),
],
)
try:
task = asyncio.create_task(_persist_existing())
except RuntimeError:
return None
def _handle_completion(done_task: asyncio.Task[None]) -> None:
try:
done_task.result()
except Exception:
logger.exception(
"retrospective_learning_event_failed",
extra={
"details": {
"user_id": user_id,
"conversation_id": conversation_id,
}
},
)
task.add_done_callback(_handle_completion)
return task

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from uuid import uuid4
from app.agents.schemas.learning import LearningSignal, PatternCandidate
class LearningPatternMiner:
def mine(self, signals: list[LearningSignal]) -> list[PatternCandidate]:
patterns: list[PatternCandidate] = []
for signal in signals:
if signal.signal_type not in {"workflow", "decomposition", "preference"}:
continue
description = self._build_description(signal)
patterns.append(
PatternCandidate(
pattern_id=f"pattern-{uuid4().hex[:10]}",
pattern_type=signal.signal_type,
description=description,
confidence=signal.confidence,
evidence_refs=signal.evidence_refs[:4],
)
)
return patterns
@staticmethod
def _build_description(signal: LearningSignal) -> str:
payload = signal.payload or {}
if signal.signal_type == "workflow":
task_type = payload.get("task_type") or "general"
execution_mode = payload.get("execution_mode") or "direct"
return f"Completed {task_type} requests worked under {execution_mode} execution."
if signal.signal_type == "decomposition":
task_count = payload.get("task_count") or 0
return f"Requests with {task_count} concrete task refs benefit from structured decomposition."
if signal.signal_type == "preference":
preference = payload.get("preference") or "structured response"
return f"User preference repeatedly points to {preference}."
return signal.explanation or signal.signal_type

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from typing import Any
from app.agents.schemas.learning import SessionRetrospective
def _classify_task_type(query_text: str) -> str:
normalized = (query_text or "").lower()
if any(token in normalized for token in ("总结", "分析", "对比", "report", "analyze")):
return "analysis"
if any(token in normalized for token in ("安排", "提醒", "日程", "todo", "task")):
return "planning_or_execution"
if any(token in normalized for token in ("文档", "资料", "年报", "search", "")):
return "retrieval"
return "general"
def build_session_retrospective(
*,
request_id: str,
session_id: str,
user_query: str,
state: dict[str, Any] | None,
runtime_context: dict[str, Any] | None = None,
) -> SessionRetrospective:
state = state or {}
if hasattr(runtime_context, "model_dump"):
runtime_context = runtime_context.model_dump(mode="json")
runtime_context = runtime_context or {}
skill_shortlist = state.get("skill_shortlist") or []
used_skill_names = [
item.get("skill_name")
for item in skill_shortlist
if isinstance(item, dict) and item.get("skill_name")
]
task_refs = []
for task in (state.get("completed_tasks") or [])[:4]:
if isinstance(task, dict):
task_refs.append(
{
"task_id": task.get("task_id"),
"title": task.get("title"),
"status": task.get("status"),
}
)
event_refs = []
for event in (state.get("event_trace") or [])[:8]:
if isinstance(event, dict):
event_refs.append(
{
"event_type": event.get("event_type"),
"task_id": event.get("task_id"),
"agent_id": event.get("agent_id"),
}
)
verification_evidence = []
for evidence in (state.get("verification_evidence") or [])[:6]:
if isinstance(evidence, dict):
verification_evidence.append(evidence)
verification_status = state.get("verification_status")
execution_mode = state.get("execution_mode")
primary_agent = state.get("current_agent") or "master"
retrospective_shortlist = state.get("retrospective_shortlist") or []
summary_parts = [
f"本轮请求按 {execution_mode or 'unknown'} 模式处理",
f"主要负责 agent 为 {primary_agent}",
]
if verification_status:
summary_parts.append(f"验证结果为 {verification_status}")
if used_skill_names:
summary_parts.append(f"命中技能候选 {', '.join(used_skill_names[:3])}")
if retrospective_shortlist:
summary_parts.append(f"参考了 {len(retrospective_shortlist)} 条历史复盘")
final_response = state.get("final_response")
outcome = "completed" if final_response else "failed"
if not final_response and verification_status == "passed":
outcome = "completed"
if final_response and verification_status == "skipped":
outcome = "partial"
return SessionRetrospective(
retrospective_id=request_id,
user_id=str(runtime_context.get("user_id") or ""),
conversation_id=session_id,
response_message_id=request_id,
query_text=user_query,
final_response=final_response,
summary="".join(summary_parts) + "",
task_type=_classify_task_type(user_query),
execution_mode=execution_mode,
primary_agent=primary_agent,
verification_status=verification_status,
verification_summary=state.get("verification_summary"),
used_skill_names=used_skill_names,
evidence_refs=verification_evidence,
task_refs=task_refs,
event_refs=event_refs,
context_snapshot={
"runtime_request_context": runtime_context,
"recommended_runtime_mode": runtime_context.get("recommended_runtime_mode"),
"parallel_worthiness": state.get("parallel_worthiness"),
"retrospective_shortlist_count": len(retrospective_shortlist),
"scheduled_subtask_count": len(state.get("scheduled_subtasks") or []),
"merge_report": dict(state.get("merge_report") or {}),
"verification_report": dict(state.get("verification_report") or {}),
},
outcome=outcome,
)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from app.agents.schemas.learning import SessionRetrospective
from app.agents.skills.matcher import score_text_match
from app.agents.learning.store import SessionRetrospectiveStore
from app.config import settings
class SessionRetrospectiveSearch:
def __init__(self, db):
self.db = db
async def shortlist(
self,
*,
user_id: str,
query_text: str,
conversation_id: str | None = None,
task_type: str | None = None,
skill_name: str | None = None,
limit: int = 3,
) -> list[SessionRetrospective]:
records = await SessionRetrospectiveStore(self.db).list_recent(user_id=user_id, limit=25)
scored: list[tuple[float, SessionRetrospective]] = []
for record in records:
if task_type and record.task_type != task_type:
continue
if skill_name and skill_name not in (record.skill_names or []):
continue
score, _matched_terms = score_text_match(
query_text,
record.query_text,
record.summary_text,
" ".join(record.skill_names or []),
)
if conversation_id and record.conversation_id == conversation_id:
score = min(1.0, score + 0.1)
if score <= 0:
continue
payload = dict(record.payload or {})
payload["retrospective_id"] = record.id
retrospective = SessionRetrospective.model_validate(payload)
scored.append((score, retrospective))
scored.sort(key=lambda item: item[0], reverse=True)
return [item for _score, item in scored[:limit]]
async def search_recent_retrospectives(
db,
*,
user_id: str,
query: str,
conversation_id: str | None = None,
task_type: str | None = None,
skill_name: str | None = None,
limit: int = 3,
) -> list[SessionRetrospective]:
if not settings.ENABLE_SESSION_RETROSPECTIVE_SEARCH:
return []
return await SessionRetrospectiveSearch(db).shortlist(
user_id=user_id,
query_text=query,
conversation_id=conversation_id,
task_type=task_type,
skill_name=skill_name,
limit=limit,
)
def summarize_retrospective(retrospective: SessionRetrospective) -> dict[str, object]:
verification_status = retrospective.verification_status or retrospective.outcome
success_score = 1.0 if verification_status == "passed" else 0.6 if verification_status == "skipped" else 0.2
reusable_patterns = []
if retrospective.used_skill_names:
reusable_patterns.append("skill_shortlist_hit")
if retrospective.execution_mode:
reusable_patterns.append(f"mode:{retrospective.execution_mode}")
avoid_patterns = []
if retrospective.outcome == "failed":
avoid_patterns.append("failed_outcome")
return {
"retrospective_id": retrospective.retrospective_id,
"task_type": retrospective.task_type,
"request_summary": retrospective.query_text[:120],
"summary": retrospective.summary,
"execution_mode": retrospective.execution_mode,
"success_score": round(success_score, 2),
"reusable_patterns": reusable_patterns,
"avoid_patterns": avoid_patterns,
}

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from app.agents.schemas.learning import LearningSignal, SessionRetrospective
class RetrospectiveSignalExtractor:
def extract(self, retrospective: SessionRetrospective) -> list[LearningSignal]:
signals: list[LearningSignal] = []
if retrospective.outcome == "completed":
signals.append(
LearningSignal(
signal_type="workflow",
confidence=0.8,
evidence_refs=retrospective.evidence_refs[:3],
explanation="Completed runs can be mined as workflow hints later.",
payload={
"task_type": retrospective.task_type,
"execution_mode": retrospective.execution_mode,
},
)
)
if len(retrospective.task_refs) > 1:
context_snapshot = retrospective.context_snapshot or {}
merge_report = dict(context_snapshot.get("merge_report") or {})
verification_report = dict(context_snapshot.get("verification_report") or {})
effectiveness_score = 1.0
if merge_report.get("status") == "conflicted":
effectiveness_score = 0.45
elif merge_report.get("status") == "fallback":
effectiveness_score = 0.25
elif verification_report.get("status") == "failed":
effectiveness_score = 0.3
signals.append(
LearningSignal(
signal_type="decomposition",
confidence=0.7,
evidence_refs=retrospective.task_refs[:3],
explanation="Multiple completed task refs indicate a decomposition pattern.",
payload={
"task_count": len(retrospective.task_refs),
"scheduled_subtask_count": context_snapshot.get("scheduled_subtask_count", 0),
"effectiveness_score": effectiveness_score,
"merge_status": merge_report.get("status"),
},
)
)
if retrospective.used_skill_names:
signals.append(
LearningSignal(
signal_type="tool_success",
confidence=0.65 if retrospective.outcome == "completed" else 0.35,
evidence_refs=retrospective.evidence_refs[:2],
explanation="Task-scoped skill shortlist was available during this run.",
payload={"skills": retrospective.used_skill_names[:3]},
)
)
if retrospective.outcome == "failed":
signals.append(
LearningSignal(
signal_type="correction",
confidence=0.75,
evidence_refs=retrospective.evidence_refs[:2],
explanation="Failed retrospectives should remain auditable before any promotion.",
payload={"verification_status": retrospective.verification_status},
)
)
return signals

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import hashlib
from app.agents.schemas.learning import PatternCandidate, SkillCandidate
class SkillCandidateBuilder:
def build(self, patterns: list[PatternCandidate]) -> list[SkillCandidate]:
candidates: list[SkillCandidate] = []
for pattern in patterns:
if pattern.confidence < 0.55:
continue
name = self._build_name(pattern)
candidates.append(
SkillCandidate(
candidate_id=f"candidate-{self._stable_suffix(pattern)}",
name=name,
summary=pattern.description,
candidate_type=self._map_candidate_type(pattern.pattern_type),
source_pattern_ids=[pattern.pattern_id],
confidence=pattern.confidence,
evidence_refs=pattern.evidence_refs[:4],
recommended_status="candidate",
)
)
return candidates
@staticmethod
def _build_name(pattern: PatternCandidate) -> str:
prefix = {
"workflow": "workflow",
"decomposition": "decomposition",
"preference": "preference",
}.get(pattern.pattern_type, "learned")
stable_suffix = SkillCandidateBuilder._stable_suffix(pattern)
return f"{prefix}-{stable_suffix}"
@staticmethod
def _map_candidate_type(pattern_type: str) -> str:
mapping = {
"workflow": "workflow_skill",
"decomposition": "decomposition_skill",
"preference": "preference_skill",
}
return mapping.get(pattern_type, "workflow_skill")
@staticmethod
def _stable_suffix(pattern: PatternCandidate) -> str:
raw = f"{pattern.pattern_type}:{pattern.description}".encode("utf-8")
return hashlib.sha1(raw).hexdigest()[:10]

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.schemas.learning import SessionRetrospective
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
class SessionRetrospectiveStore:
def __init__(self, db: AsyncSession):
self.db = db
async def save(self, retrospective: SessionRetrospective) -> SessionRetrospectiveRecord:
payload = retrospective.model_dump(mode="json")
record = SessionRetrospectiveRecord(
user_id=retrospective.user_id,
conversation_id=retrospective.conversation_id,
request_message_id=retrospective.request_message_id,
response_message_id=retrospective.response_message_id,
query_text=retrospective.query_text,
final_response=retrospective.final_response,
summary_text=retrospective.summary,
task_type=retrospective.task_type,
execution_mode=retrospective.execution_mode,
primary_agent=retrospective.primary_agent,
verification_status=retrospective.verification_status,
verification_summary=retrospective.verification_summary,
skill_names=retrospective.used_skill_names,
evidence=retrospective.evidence_refs,
task_refs=retrospective.task_refs,
payload=payload,
)
self.db.add(record)
await self.db.commit()
await self.db.refresh(record)
return record
async def list_recent(
self,
*,
user_id: str,
limit: int = 20,
) -> list[SessionRetrospectiveRecord]:
result = await self.db.execute(
select(SessionRetrospectiveRecord)
.where(SessionRetrospectiveRecord.user_id == user_id)
.order_by(desc(SessionRetrospectiveRecord.recorded_at), desc(SessionRetrospectiveRecord.created_at))
.limit(limit)
)
return list(result.scalars().all())
class LearningArtifactStore:
def __init__(self, db: AsyncSession):
self.db = db
async def save_batch(
self,
*,
user_id: str,
conversation_id: str,
retrospective_id: str | None,
artifacts: list[dict[str, object]],
) -> list[LearningArtifactRecord]:
records: list[LearningArtifactRecord] = []
for artifact in artifacts:
record = LearningArtifactRecord(
user_id=user_id,
conversation_id=conversation_id,
retrospective_id=retrospective_id,
artifact_type=str(artifact.get("artifact_type") or "unknown"),
artifact_key=str(artifact.get("artifact_key") or "") or None,
summary_text=str(artifact.get("summary_text") or ""),
payload=dict(artifact.get("payload") or {}),
)
self.db.add(record)
records.append(record)
await self.db.commit()
for record in records:
await self.db.refresh(record)
return records
async def list_recent(
self,
*,
user_id: str,
artifact_type: str | None = None,
limit: int = 50,
) -> list[LearningArtifactRecord]:
query = select(LearningArtifactRecord).where(LearningArtifactRecord.user_id == user_id)
if artifact_type:
query = query.where(LearningArtifactRecord.artifact_type == artifact_type)
result = await self.db.execute(
query.order_by(
desc(LearningArtifactRecord.recorded_at),
desc(LearningArtifactRecord.created_at),
).limit(limit)
)
return list(result.scalars().all())
async def aggregate_counts_by_key(
self,
*,
user_id: str,
artifact_type: str,
limit: int = 100,
) -> dict[str, int]:
records = await self.list_recent(user_id=user_id, artifact_type=artifact_type, limit=limit)
counts: dict[str, int] = {}
for record in records:
key = record.artifact_key or "unknown"
counts[key] = counts.get(key, 0) + 1
return counts
def append_retrospective_attachment(
attachments: list[dict] | None,
retrospective: SessionRetrospective,
) -> list[dict]:
next_attachments = list(attachments or [])
next_attachments.append(
{
"kind": "session_retrospective",
"payload": retrospective.model_dump(mode="json"),
}
)
return next_attachments

View File

@@ -1,7 +1,16 @@
"""高级编排系统 - Phase 10""" """高级编排系统 - Phase 10"""
from app.agents.orchestration.budget import build_subtask_budget
from app.agents.orchestration.result_merge import merge_task_results
from app.agents.orchestration.scheduler import (
ParallelExecutionScheduler,
build_subtask_specs,
ensure_child_links,
)
from app.agents.orchestration.subagent_runtime import subtask_spec_to_agent_task
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
from app.agents.transport.remote import RemoteTransport, StructuredMessage from app.agents.transport.remote import RemoteTransport, StructuredMessage
from app.agents.orchestration.task_graph import build_bounded_task_graph, render_task_graph_summary
from app.agents.background.manager import ( from app.agents.background.manager import (
BackgroundTaskManager, BackgroundTaskManager,
BackgroundTask, BackgroundTask,
@@ -14,7 +23,15 @@ __all__ = [
"TaskStatus", "TaskStatus",
"RemoteTransport", "RemoteTransport",
"StructuredMessage", "StructuredMessage",
"ParallelExecutionScheduler",
"build_bounded_task_graph",
"build_subtask_budget",
"build_subtask_specs",
"BackgroundTaskManager", "BackgroundTaskManager",
"BackgroundTask", "BackgroundTask",
"ensure_child_links",
"get_background_task_manager", "get_background_task_manager",
"merge_task_results",
"render_task_graph_summary",
"subtask_spec_to_agent_task",
] ]

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from app.agents.schemas.task import CollaborationBudget
def build_subtask_budget(
*,
execution_mode: str,
max_parallel_tasks: int,
max_tool_calls: int = 2,
max_iterations: int = 2,
metadata: dict | None = None,
) -> CollaborationBudget:
return CollaborationBudget(
mode="collaboration" if execution_mode != "direct" else "direct",
max_parallel_tasks=max_parallel_tasks,
remaining_parallel_tasks=max_parallel_tasks,
max_tool_calls=max_tool_calls,
remaining_tool_calls=max_tool_calls,
max_iterations=max_iterations,
remaining_iterations=max_iterations,
escalation_threshold=1,
metadata=metadata or {},
)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any
def build_parallel_runtime_metrics(
*,
task_graph: dict[str, Any] | None,
scheduled_subtasks: list[dict[str, Any]] | None,
task_results: list[dict[str, Any]] | None,
merge_report: dict[str, Any] | None,
) -> dict[str, Any]:
task_graph = task_graph or {}
scheduled_subtasks = list(scheduled_subtasks or [])
task_results = list(task_results or [])
merge_report = merge_report or {}
completed = sum(1 for item in task_results if item.get("status") == "completed")
failed = sum(1 for item in task_results if item.get("status") == "failed")
blocked = sum(1 for item in task_results if item.get("status") == "blocked")
return {
"task_graph_node_count": len(task_graph.get("nodes") or []),
"scheduled_subtask_count": len(scheduled_subtasks),
"completed_subtask_count": completed,
"failed_subtask_count": failed,
"blocked_subtask_count": blocked,
"merge_status": merge_report.get("status"),
"merge_conflict_count": len(merge_report.get("conflict_flags") or []),
"fallback_used": bool(merge_report.get("fallback_used") or False),
}

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from app.agents.schemas.orchestration import MergeReport
from app.agents.verifier import normalize_task_result
def merge_task_results(task_results: list[dict] | list[object]) -> MergeReport:
normalized = [normalize_task_result(item) for item in (task_results or [])]
completed = [item for item in normalized if item.status == "completed"]
failed_or_blocked = [item for item in normalized if item.status in {"failed", "blocked"}]
evidence_union: list[dict] = []
summaries = []
for item in normalized:
evidence_union.extend(list(item.evidence or []))
if item.summary:
summaries.append(item.summary.strip())
unique_summaries = list(dict.fromkeys(summary for summary in summaries if summary))
conflict_flags: list[str] = []
status = "merged"
fallback_used = False
if failed_or_blocked:
status = "fallback"
fallback_used = True
conflict_flags.append(
"failed_or_blocked_tasks:" + ",".join(item.task_id for item in failed_or_blocked)
)
resolution_strategy = "serial_recovery"
resolved_summary = (
completed[-1].summary
if completed and completed[-1].summary
else None
)
elif len(unique_summaries) > 1 and len(completed) > 1:
status = "conflicted"
conflict_flags.append("multiple_distinct_completed_summaries")
resolution_strategy = "rank_by_evidence_count"
ranked = sorted(
completed,
key=lambda item: (len(item.evidence or []), bool(item.summary)),
reverse=True,
)
resolved_summary = ranked[0].summary if ranked and ranked[0].summary else None
else:
resolution_strategy = "evidence_union"
resolved_summary = unique_summaries[-1] if unique_summaries else None
if status == "merged":
summary = (
unique_summaries[-1]
if unique_summaries
else f"已收敛 {len(normalized)} 个子任务结果。"
)
elif status == "conflicted":
summary = "并行子任务摘要存在冲突,需要 verifier 或串行收敛。"
else:
summary = "存在失败或阻塞子任务,需要回退到更保守的收敛路径。"
return MergeReport(
status=status,
summary=summary,
evidence_union=evidence_union,
conflict_flags=conflict_flags,
resolution_strategy=resolution_strategy,
resolved_summary=resolved_summary,
fallback_used=fallback_used,
)

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from collections import defaultdict, deque
from uuid import uuid4
from app.agents.orchestration.budget import build_subtask_budget
from app.agents.schemas.orchestration import SubTaskSpec, TaskGraph, TaskNode
class ParallelExecutionScheduler:
def plan(self, task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
ordered_nodes = _topological_nodes(task_graph)
specs: list[SubTaskSpec] = []
for node in ordered_nodes:
budget = build_subtask_budget(
execution_mode=node.execution_mode,
max_parallel_tasks=max(1, task_graph.max_parallelism),
metadata={
"task_graph_id": task_graph.graph_id,
"depends_on": node.depends_on,
},
)
specs.append(
SubTaskSpec(
subtask_id=node.node_id,
parent_run_id=task_graph.graph_id,
title=node.title,
role=node.role or "master",
goal=node.goal or query_text,
context_slice=_build_context_slice(node, query_text),
allowed_tools=[],
budget_tokens=1200,
budget_tool_calls=budget.max_tool_calls or 2,
expected_output_schema={
"summary": "string",
"evidence": "list",
"status": "completed|failed|blocked",
},
expected_evidence=node.expected_evidence,
dependencies=node.depends_on,
)
)
return specs
def build_subtask_specs(task_graph: TaskGraph, *, query_text: str) -> list[SubTaskSpec]:
return ParallelExecutionScheduler().plan(task_graph, query_text=query_text)
def _build_context_slice(node: TaskNode, query_text: str) -> dict[str, object]:
return {
"query": query_text,
"role": node.role,
"title": node.title,
"goal": node.goal,
"depends_on": node.depends_on,
}
def _topological_nodes(task_graph: TaskGraph) -> list[TaskNode]:
by_id = {node.node_id: node for node in task_graph.nodes}
indegree = {node.node_id: 0 for node in task_graph.nodes}
edges: dict[str, list[str]] = defaultdict(list)
for node in task_graph.nodes:
for dep in node.depends_on:
if dep not in by_id:
continue
edges[dep].append(node.node_id)
indegree[node.node_id] += 1
ready = deque(node_id for node_id, count in indegree.items() if count == 0)
ordered: list[TaskNode] = []
while ready:
node_id = ready.popleft()
ordered.append(by_id[node_id])
for target in edges.get(node_id, []):
indegree[target] -= 1
if indegree[target] == 0:
ready.append(target)
if len(ordered) != len(task_graph.nodes):
return list(task_graph.nodes)
return ordered
def ensure_child_links(specs: list[SubTaskSpec]) -> dict[str, list[str]]:
graph: dict[str, list[str]] = defaultdict(list)
for spec in specs:
for dep in spec.dependencies:
graph[dep].append(spec.subtask_id)
return dict(graph)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from app.agents.schemas.orchestration import SubTaskSpec
from app.agents.schemas.task import AgentTask
def subtask_spec_to_agent_task(spec: SubTaskSpec) -> AgentTask:
return AgentTask(
task_id=spec.subtask_id,
title=spec.title,
owner_agent_id=spec.role,
role=spec.role,
goal=spec.goal,
parent_task_id=spec.parent_run_id,
child_task_ids=[],
expected_evidence=spec.expected_evidence,
)

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from uuid import uuid4
from app.agents.schemas.orchestration import ParallelWorthiness, TaskGraph, TaskNode
ROLE_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("librarian", ("", "检索", "资料", "文档", "知识库", "年报", "forum", "search")),
("analyst", ("分析", "判断", "风险", "总结", "对比", "洞察", "结论")),
("schedule_planner", ("计划", "安排", "下周", "日程", "提醒", "优先级")),
("executor", ("执行", "创建", "更新", "落库", "提交", "发帖")),
]
def build_bounded_task_graph(
*,
query_text: str,
parallel_worthiness: ParallelWorthiness,
max_nodes: int = 4,
) -> TaskGraph | None:
roles = _infer_roles(query_text)
if not roles:
return None
independent_roles = roles[: min(max_nodes - 1, max(1, parallel_worthiness.estimated_subtasks))]
nodes: list[TaskNode] = []
for index, role in enumerate(independent_roles, start=1):
node_id = f"task-{index}-{uuid4().hex[:6]}"
nodes.append(
TaskNode(
node_id=node_id,
title=_build_title(role),
role=role,
goal=_build_goal(role, query_text),
depends_on=[],
execution_mode=(
"parallel"
if parallel_worthiness.preferred_mode in {"collaboration", "parallel"}
and len(independent_roles) > 1
else "serial"
),
expected_evidence=_build_expected_evidence(role),
)
)
if len(nodes) > 1:
merge_id = f"merge-{uuid4().hex[:6]}"
nodes.append(
TaskNode(
node_id=merge_id,
title="汇总并收敛最终结论",
role="master",
goal="汇总前置子任务结果,形成统一可验证的输出。",
depends_on=[node.node_id for node in nodes],
execution_mode="serial",
expected_evidence=[{"type": "merge", "detail": "merged summary and conflict notes"}],
)
)
return TaskGraph(
nodes=nodes,
entry_node_ids=[node.node_id for node in nodes if not node.depends_on],
max_parallelism=max(1, len(independent_roles)),
rationale=_build_rationale(parallel_worthiness, independent_roles),
)
def render_task_graph_summary(task_graph: TaskGraph | None) -> str | None:
if task_graph is None or not task_graph.nodes:
return None
lines = ["- 任务图:"]
for node in task_graph.nodes[:4]:
deps = f" deps={','.join(node.depends_on)}" if node.depends_on else ""
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}){deps}")
return "\n".join(lines)
def _infer_roles(query_text: str) -> list[str]:
selected: list[str] = []
text = query_text or ""
for role, keywords in ROLE_KEYWORDS:
if any(keyword in text for keyword in keywords):
selected.append(role)
if not selected:
return ["analyst"]
return selected
def _build_title(role: str) -> str:
mapping = {
"librarian": "收集事实与外部/内部证据",
"analyst": "形成判断与风险分析",
"schedule_planner": "整理计划和优先级",
"executor": "执行必要操作并回收结果",
}
return mapping.get(role, "处理子任务")
def _build_goal(role: str, query_text: str) -> str:
mapping = {
"librarian": f"围绕请求收集支持结论的事实和资料:{query_text}",
"analyst": f"基于当前请求输出结构化判断:{query_text}",
"schedule_planner": f"把当前请求收束为计划、安排或优先级:{query_text}",
"executor": f"基于请求执行必要动作并返回结果:{query_text}",
}
return mapping.get(role, query_text)
def _build_expected_evidence(role: str) -> list[dict[str, str]]:
mapping = {
"librarian": [{"type": "evidence", "detail": "retrieval findings"}],
"analyst": [{"type": "analysis", "detail": "structured judgment"}],
"schedule_planner": [{"type": "plan", "detail": "explicit schedule or priorities"}],
"executor": [{"type": "execution", "detail": "tool output or mutation result"}],
}
return mapping.get(role, [{"type": "summary", "detail": "task summary"}])
def _build_rationale(parallel_worthiness: ParallelWorthiness, roles: list[str]) -> str:
return (
f"preferred_mode={parallel_worthiness.preferred_mode}; "
f"score={parallel_worthiness.score:.2f}; "
f"roles={','.join(roles)}"
)

View File

@@ -1,5 +1,24 @@
from app.agents.schemas.event import AgentEvent from app.agents.schemas.event import AgentEvent
from app.agents.schemas.learning import (
LearningDecision,
LearningSignal,
PatternCandidate,
SessionRetrospective,
SkillCandidate,
)
from app.agents.schemas.message import AgentMessage from app.agents.schemas.message import AgentMessage
from app.agents.schemas.orchestration import (
ExecutionDecision,
MergeReport,
ParallelWorthiness,
RuntimeRequestContext,
SubTaskResult,
SubTaskSpec,
TaskGraph,
TaskNode,
VerificationReport,
)
from app.agents.schemas.skills import SkillActivationRecord, SkillShortlistEntry
from app.agents.schemas.task import ( from app.agents.schemas.task import (
AgentTask, AgentTask,
CollaborationBudget, CollaborationBudget,
@@ -14,12 +33,28 @@ from app.agents.schemas.task import (
__all__ = [ __all__ = [
"AgentEvent", "AgentEvent",
"AgentMessage", "AgentMessage",
"ExecutionDecision",
"AgentTask", "AgentTask",
"CollaborationBudget", "CollaborationBudget",
"InterruptRecord", "InterruptRecord",
"LearningDecision",
"LearningSignal",
"MergeReport",
"ParallelWorthiness",
"PatternCandidate",
"RecoveryRecord", "RecoveryRecord",
"RuntimeRequestContext",
"SessionRetrospective",
"SkillActivationRecord",
"SkillCandidate",
"SkillShortlistEntry",
"SubTaskResult",
"SubTaskSpec",
"TaskGraph",
"TaskNode",
"TaskLifecycleStatus", "TaskLifecycleStatus",
"TaskResult", "TaskResult",
"TaskResultStatus", "TaskResultStatus",
"VerificationReport",
"VerificationStatus", "VerificationStatus",
] ]

View File

@@ -7,10 +7,21 @@ from pydantic import BaseModel, Field
AgentEventType = Literal[ AgentEventType = Literal[
"agent.execution.decided",
"agent.parallel.assessed",
"agent.skill.shortlisted",
"agent.task_graph.built",
"agent.subtask.started",
"agent.subtask.completed",
"agent.merge.completed",
"agent.tool.start", "agent.tool.start",
"agent.tool.result", "agent.tool.result",
"agent.verify.started", "agent.verify.started",
"agent.verify.completed", "agent.verify.completed",
"agent.retrospective.created",
"agent.learning.decision",
"agent.skill.lifecycle.changed",
"agent.rollback.triggered",
"agent.created", "agent.created",
"agent.spawn.blocked", "agent.spawn.blocked",
"agent.message.sent", "agent.message.sent",

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal
from pydantic import BaseModel, Field
LearningSignalType = Literal[
"preference",
"workflow",
"decomposition",
"tool_success",
"correction",
]
class SessionRetrospective(BaseModel):
retrospective_id: str | None = None
user_id: str
conversation_id: str
request_message_id: str | None = None
response_message_id: str | None = None
query_text: str
final_response: str | None = None
summary: str
task_type: str | None = None
execution_mode: str | None = None
primary_agent: str | None = None
verification_status: str | None = None
verification_summary: str | None = None
used_skill_names: list[str] = Field(default_factory=list)
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
task_refs: list[dict[str, Any]] = Field(default_factory=list)
event_refs: list[dict[str, Any]] = Field(default_factory=list)
context_snapshot: dict[str, Any] = Field(default_factory=dict)
learning_signals: list["LearningSignal"] = Field(default_factory=list)
pattern_candidates: list["PatternCandidate"] = Field(default_factory=list)
skill_candidates: list["SkillCandidate"] = Field(default_factory=list)
learning_decision: "LearningDecision | None" = None
outcome: Literal["completed", "partial", "failed"] = "completed"
captured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class LearningSignal(BaseModel):
signal_type: LearningSignalType
confidence: float = 0.0
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
explanation: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
class PatternCandidate(BaseModel):
pattern_id: str
pattern_type: str
description: str
confidence: float = 0.0
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
class SkillCandidate(BaseModel):
candidate_id: str
name: str
summary: str
candidate_type: Literal["workflow_skill", "preference_skill", "decomposition_skill"] = "workflow_skill"
source_pattern_ids: list[str] = Field(default_factory=list)
confidence: float = 0.0
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
recommended_status: Literal["candidate", "shadow"] = "candidate"
class LearningDecision(BaseModel):
decision: Literal["reinforce_memory", "create_candidate", "promote_skill", "defer", "reject"]
explanation: str
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any, Literal
from uuid import uuid4
from pydantic import BaseModel, Field
from app.agents.schemas.skills import SkillShortlistEntry
ExecutionMode = Literal["direct", "collaboration", "parallel", "delegated"]
ParallelPreference = Literal["direct", "collaboration", "parallel"]
class ParallelWorthiness(BaseModel):
should_parallelize: bool = False
score: float = 0.0
estimated_subtasks: int = 1
preferred_mode: ParallelPreference = "direct"
reasons: list[str] = Field(default_factory=list)
risk_flags: list[str] = Field(default_factory=list)
class TaskNode(BaseModel):
node_id: str
title: str
role: str | None = None
goal: str | None = None
depends_on: list[str] = Field(default_factory=list)
execution_mode: Literal["serial", "parallel"] = "serial"
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
class TaskGraph(BaseModel):
graph_id: str = Field(default_factory=lambda: str(uuid4()))
nodes: list[TaskNode] = Field(default_factory=list)
entry_node_ids: list[str] = Field(default_factory=list)
max_parallelism: int = 1
rationale: str | None = None
class SubTaskSpec(BaseModel):
subtask_id: str
parent_run_id: str
title: str
role: str
goal: str
context_slice: dict[str, Any] = Field(default_factory=dict)
allowed_tools: list[str] = Field(default_factory=list)
budget_tokens: int = 1200
budget_tool_calls: int = 2
expected_output_schema: dict[str, Any] = Field(default_factory=dict)
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
dependencies: list[str] = Field(default_factory=list)
class SubTaskResult(BaseModel):
subtask_id: str
status: Literal["completed", "failed", "blocked"]
summary: str | None = None
evidence: list[dict[str, Any]] = Field(default_factory=list)
output: dict[str, Any] = Field(default_factory=dict)
class MergeReport(BaseModel):
merge_id: str = Field(default_factory=lambda: str(uuid4()))
status: Literal["merged", "conflicted", "fallback"]
summary: str | None = None
evidence_union: list[dict[str, Any]] = Field(default_factory=list)
conflict_flags: list[str] = Field(default_factory=list)
resolution_strategy: str | None = None
resolved_summary: str | None = None
fallback_used: bool = False
class VerificationReport(BaseModel):
status: Literal["passed", "failed", "skipped"]
summary: str | None = None
evidence: list[dict[str, Any]] = Field(default_factory=list)
class ExecutionDecision(BaseModel):
request_id: str = Field(default_factory=lambda: str(uuid4()))
mode: ExecutionMode = "direct"
reason: str
complexity_score: float = 0.0
parallel_worthiness_score: float | None = None
selected_roles: list[str] = Field(default_factory=list)
class RuntimeRequestContext(BaseModel):
request_id: str = Field(default_factory=lambda: str(uuid4()))
session_id: str | None = None
user_id: str
conversation_id: str | None = None
query_text: str | None = None
raw_user_query: str | None = None
recalled_memories: list[str] = Field(default_factory=list)
retrospective_shortlist: list[dict[str, Any]] = Field(default_factory=list)
recalled_retrospectives: list[dict[str, Any]] = Field(default_factory=list)
skill_shortlist: list[SkillShortlistEntry] = Field(default_factory=list)
shortlisted_skills: list[str] = Field(default_factory=list)
parallel_worthiness: ParallelWorthiness = Field(default_factory=ParallelWorthiness)
task_graph: TaskGraph | None = None
recommended_runtime_mode: Literal["direct", "collaboration"] = "direct"
execution_mode: Literal["direct", "collaboration"] | None = None
current_agent_role: str | None = None
conversation_state_ref: str | None = None
assembly_metrics: dict[str, float] = Field(default_factory=dict)
assembled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def assess_parallel_worthiness(
query_text: str,
*,
retrospective_count: int = 0,
skill_count: int = 0,
) -> ParallelWorthiness:
normalized = (query_text or "").strip().lower()
reasons: list[str] = []
score = 0.0
multi_step_markers = ("然后", "接着", "同时", "并且", "最后", "汇总", "对比", "分析", "research")
artifact_markers = ("文档", "代码", "文件", "数据库", "论坛", "知识库", "计划")
if any(marker in normalized for marker in multi_step_markers):
score += 0.35
reasons.append("multi_step_request")
if sum(1 for marker in artifact_markers if marker in normalized) >= 2:
score += 0.25
reasons.append("multi_source_context")
if len(re.findall(r"[,、;;]", query_text or "")) >= 2:
score += 0.15
reasons.append("compound_instruction")
if retrospective_count > 0:
score += 0.1
reasons.append("historical_support")
if skill_count > 0:
score += 0.1
reasons.append("skill_candidates_available")
score = min(score, 1.0)
should_parallelize = score >= 0.55
preferred_mode: ParallelPreference = "parallel" if should_parallelize else "direct"
if not should_parallelize and score >= 0.3:
preferred_mode = "collaboration"
estimated_subtasks = 1
if preferred_mode == "parallel":
estimated_subtasks = 3 if score >= 0.8 else 2
elif preferred_mode == "collaboration":
estimated_subtasks = 2
return ParallelWorthiness(
should_parallelize=should_parallelize,
score=round(score, 3),
estimated_subtasks=estimated_subtasks,
preferred_mode=preferred_mode,
reasons=reasons,
)
def render_runtime_request_context_summary(context: RuntimeRequestContext) -> str:
lines = ["【Runtime Request Context】"]
lines.append(f"- 推荐运行模式: {context.recommended_runtime_mode}")
lines.append(
f"- 并行潜力: score={context.parallel_worthiness.score:.2f}, "
f"preferred={context.parallel_worthiness.preferred_mode}, "
f"estimated_subtasks={context.parallel_worthiness.estimated_subtasks}"
)
if context.parallel_worthiness.reasons:
lines.append(f"- 并行判断依据: {', '.join(context.parallel_worthiness.reasons)}")
if context.assembly_metrics:
total_ms = context.assembly_metrics.get("total_ms")
if total_ms is not None:
lines.append(f"- 上下文装配耗时: {total_ms:.1f} ms")
if context.task_graph and context.task_graph.nodes:
lines.append(
f"- 任务图: nodes={len(context.task_graph.nodes)}, max_parallelism={context.task_graph.max_parallelism}"
)
for node in context.task_graph.nodes[:4]:
deps = f", deps={len(node.depends_on)}" if node.depends_on else ""
lines.append(f" - [{node.execution_mode}] {node.title} ({node.role}{deps})")
if context.retrospective_shortlist:
lines.append("- 历史复盘命中:")
for item in context.retrospective_shortlist[:3]:
summary = (item.get("summary") or item.get("summary_text") or "").strip()
task_type = item.get("task_type") or "unknown"
lines.append(f" - [{task_type}] {summary[:160]}")
if context.skill_shortlist:
lines.append("- 技能候选:")
for item in context.skill_shortlist[:3]:
lines.append(
f" - {item.skill_name} ({item.injection_mode}, score={item.score:.2f})"
+ (f": {item.rationale}" if item.rationale else "")
)
if context.recalled_memories:
lines.append("- 记忆上下文已装配,可在回答中按需引用。")
return "\n".join(lines)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal
from pydantic import BaseModel, Field
SkillStatus = Literal["candidate", "shadow", "active", "deprecated", "retired"]
SkillInjectionMode = Literal["metadata_only", "summary", "full"]
class SkillShortlistEntry(BaseModel):
skill_name: str
source: str = "runtime"
source_id: str | None = None
status: SkillStatus = "active"
scope: list[str] = Field(default_factory=list)
effectiveness: float | None = None
score: float = 0.0
rationale: str | None = None
summary: str | None = None
matched_terms: list[str] = Field(default_factory=list)
injection_mode: SkillInjectionMode = "metadata_only"
metadata: dict[str, Any] = Field(default_factory=dict)
class SkillActivationRecord(BaseModel):
skill_name: str
source: str = "runtime"
source_id: str | None = None
status: SkillStatus = "active"
injection_mode: SkillInjectionMode = "metadata_only"
matched_terms: list[str] = Field(default_factory=list)
rationale: str | None = None
activated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
outcome: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -1,16 +1 @@
"""Skills 注册表 - Phase 9""" """Skill package."""
from app.agents.skills.registry import SkillRegistry, get_skill_registry
from app.agents.skills.metadata import SkillMetadata
from app.agents.skills.loaders.local_loader import LocalSkillLoader
from app.agents.skills.loaders.plugin_loader import PluginSkillLoader
from app.agents.skills.mcp_builder import MCPSkillBuilder
__all__ = [
"SkillRegistry",
"SkillMetadata",
"LocalSkillLoader",
"PluginSkillLoader",
"MCPSkillBuilder",
"get_skill_registry",
]

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from app.models.skill import Skill
def summarize_skill_effectiveness(skill: Skill) -> dict[str, object]:
return {
"name": skill.name,
"status": skill.status,
"effectiveness": skill.effectiveness,
"activation_count": skill.activation_count,
"candidate_count": getattr(skill, "candidate_count", 0),
"last_activated_at": skill.last_activated_at.isoformat() if skill.last_activated_at else None,
}

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.agents.schemas.learning import SessionRetrospective, SkillCandidate
from app.agents.skills.models import SkillLifecycleDecision
from app.services.skill_service import SkillService
class SkillPromotionEvaluator:
def __init__(self, db):
self.db = db
self.skill_service = SkillService(db)
async def sync_retrospective(
self,
*,
user_id: str,
retrospective: SessionRetrospective,
) -> list[SkillLifecycleDecision]:
decisions: list[SkillLifecycleDecision] = []
for candidate in retrospective.skill_candidates:
decisions.append(
await self.skill_service.upsert_learned_candidate(
user_id=user_id,
candidate=candidate,
primary_agent=retrospective.primary_agent,
evidence_refs=candidate.evidence_refs,
)
)
outcome_score = self._derive_outcome_score(retrospective)
for skill_name in retrospective.used_skill_names:
decision = await self.skill_service.record_activation_feedback(
user_id=user_id,
skill_name=skill_name,
outcome_score=outcome_score,
evidence_refs=retrospective.evidence_refs,
)
if decision is not None:
decisions.append(decision)
return decisions
@staticmethod
def _derive_outcome_score(retrospective: SessionRetrospective) -> float:
if retrospective.verification_status == "passed":
return 0.9
if retrospective.verification_status == "skipped":
return 0.55
if retrospective.verification_status == "failed":
return 0.15
return 0.7 if retrospective.outcome == "completed" else 0.2
def next_review_after(days: int = 7) -> datetime:
return datetime.now(UTC) + timedelta(days=days)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import re
def extract_match_terms(text: str | None) -> list[str]:
source = (text or "").lower()
terms = [token for token in re.findall(r"[a-z0-9_]+", source) if len(token) >= 3]
for chunk in re.findall(r"[\u4e00-\u9fff]+", text or ""):
if len(chunk) >= 2:
terms.append(chunk)
if len(chunk) > 4:
for index in range(len(chunk) - 1):
terms.append(chunk[index : index + 2])
return list(dict.fromkeys(terms))
def score_text_match(query_text: str, *corpus_parts: str | None) -> tuple[float, list[str]]:
query_terms = extract_match_terms(query_text)
if not query_terms:
return 0.0, []
corpus = " ".join(part for part in corpus_parts if part).lower()
matched_terms = [term for term in query_terms if term and term in corpus]
if not matched_terms:
return 0.0, []
coverage = len(matched_terms) / max(len(query_terms), 1)
density = min(len(matched_terms), 4) / 4
return round(min(1.0, coverage * 0.7 + density * 0.3), 3), matched_terms

View File

@@ -20,6 +20,10 @@ class SkillMetadata:
source_id: str = "" # 来源 ID source_id: str = "" # 来源 ID
enabled: bool = True # 是否启用 enabled: bool = True # 是否启用
tools: list[str] = field(default_factory=list) # 关联的工具 tools: list[str] = field(default_factory=list) # 关联的工具
status: str = "active" # candidate/shadow/active/deprecated/retired
scope: list[str] = field(default_factory=list)
effectiveness: float | None = None
review_after: str | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -35,6 +39,10 @@ class SkillMetadata:
"source_id": self.source_id, "source_id": self.source_id,
"enabled": self.enabled, "enabled": self.enabled,
"tools": self.tools, "tools": self.tools,
"status": self.status,
"scope": self.scope,
"effectiveness": self.effectiveness,
"review_after": self.review_after,
} }
@classmethod @classmethod

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
SkillLifecycleAction = Literal[
"created_candidate",
"promoted_to_shadow",
"promoted_to_active",
"degraded_to_deprecated",
"retired",
"reactivated",
"feedback_recorded",
"no_change",
]
class SkillLifecycleDecision(BaseModel):
skill_name: str
action: SkillLifecycleAction
previous_status: str | None = None
new_status: str
reason: str
evidence_refs: list[dict[str, object]] = Field(default_factory=list)
confidence: float | None = None
review_after: datetime | None = None

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from app.agents.schemas.skills import SkillInjectionMode, SkillShortlistEntry
MAX_SUMMARY_CHARS = 120
def choose_injection_mode(score: float, summary_available: bool) -> SkillInjectionMode:
if score >= 0.75 and summary_available:
return "summary"
return "metadata_only"
def render_skill_shortlist_context(entries: list[SkillShortlistEntry]) -> str:
if not entries:
return ""
lines = ["[Task-Scoped Skills]"]
for entry in entries[:3]:
detail = entry.summary or "Relevant to the current request."
detail = detail[:MAX_SUMMARY_CHARS]
lines.append(f"- {entry.skill_name} | mode={entry.injection_mode} | score={entry.score:.2f}")
lines.append(f" {detail}")
if entry.matched_terms:
lines.append(f" matched_terms={', '.join(entry.matched_terms[:6])}")
return "\n".join(lines)

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from collections import OrderedDict
from app.agents.schemas.skills import SkillShortlistEntry
from app.agents.skills.matcher import score_text_match
from app.agents.skills.policy import choose_injection_mode, render_skill_shortlist_context
from app.agents.skills.registry import get_skill_registry
from app.services.skill_service import SkillService
class RuntimeSkillRetriever:
def __init__(self, db):
self.db = db
async def shortlist(
self,
*,
user_id: str,
query_text: str,
memory_context: str | None = None,
retrospectives: list[dict] | None = None,
include_learned: bool = True,
limit: int = 3,
) -> list[SkillShortlistEntry]:
deduped: "OrderedDict[str, SkillShortlistEntry]" = OrderedDict()
retrospective_text = "\n".join(
(item.get("summary") or item.get("summary_text") or "")
for item in (retrospectives or [])
if isinstance(item, dict)
)
service = SkillService(self.db)
for skill in await service.list_runtime_candidates(user_id, include_learned=include_learned):
score, matched_terms = score_text_match(
query_text,
skill.name,
skill.description,
skill.instructions,
retrospective_text,
memory_context,
)
if score <= 0:
continue
entry = SkillShortlistEntry(
skill_name=skill.name,
source="database",
source_id=skill.id,
scope=[skill.agent_type, skill.visibility],
status=skill.status,
effectiveness=skill.effectiveness,
score=score,
matched_terms=matched_terms,
rationale=(
"Shadow skill matched current request; keep metadata-only injection."
if skill.status == "shadow"
else "Matched against DB skill metadata and instructions."
),
summary=skill.description or (skill.instructions[:160] if skill.instructions else None),
injection_mode=(
"metadata_only"
if skill.status == "shadow"
else choose_injection_mode(score, bool(skill.description or skill.instructions))
),
)
self._upsert(deduped, entry)
registry = get_skill_registry()
if not registry.list_all():
try:
registry.load_all()
except Exception:
pass
for skill in registry.list_all():
score, matched_terms = score_text_match(
query_text,
skill.name,
skill.description,
" ".join(skill.tags),
" ".join(skill.triggers),
skill.content[:400],
retrospective_text,
memory_context,
)
if score <= 0:
continue
entry = SkillShortlistEntry(
skill_name=skill.name,
source=skill.source,
source_id=skill.source_id or skill.id,
scope=skill.scope or list(skill.tags),
status=skill.status,
effectiveness=skill.effectiveness,
score=score,
matched_terms=matched_terms,
rationale="Matched against local or external skill metadata.",
summary=skill.description or skill.content[:160],
injection_mode=choose_injection_mode(
score,
bool(skill.description or skill.content),
),
)
self._upsert(deduped, entry)
return sorted(deduped.values(), key=lambda item: item.score, reverse=True)[:limit]
@staticmethod
def _upsert(
deduped: "OrderedDict[str, SkillShortlistEntry]",
entry: SkillShortlistEntry,
) -> None:
existing = deduped.get(entry.skill_name)
if existing is None or existing.score < entry.score:
deduped[entry.skill_name] = entry
def build_shortlisted_skill_context(
shortlist: list[dict] | list[SkillShortlistEntry] | None,
*,
agent_type: str | None = None,
) -> str:
if not shortlist:
return ""
entries: list[SkillShortlistEntry] = []
for item in shortlist:
entry = item if isinstance(item, SkillShortlistEntry) else SkillShortlistEntry.model_validate(item)
if agent_type and entry.scope and agent_type not in entry.scope:
continue
entries.append(entry)
return render_skill_shortlist_context(entries)
async def shortlist_skills_for_request(
db,
*,
user_id: str,
user_query: str,
memory_context: str | None = None,
retrospectives: list[dict] | None = None,
include_learned: bool = True,
limit: int = 3,
) -> list[SkillShortlistEntry]:
return await RuntimeSkillRetriever(db).shortlist(
user_id=user_id,
query_text=user_query,
memory_context=memory_context,
retrospectives=retrospectives,
include_learned=include_learned,
limit=limit,
)

View File

@@ -138,6 +138,18 @@ class AgentState(TypedDict):
memory_context: str | None memory_context: str | None
current_datetime_context: str | None current_datetime_context: str | None
current_datetime_reference: dict[str, str] | None current_datetime_reference: dict[str, str] | None
runtime_request_context: dict[str, Any] | None
task_graph: dict[str, Any] | None
scheduled_subtasks: list[dict[str, Any]]
recalled_retrospectives: list[dict[str, Any]]
retrospective_shortlist: list[dict[str, Any]]
skill_shortlist: list[dict[str, Any]]
skill_activation_records: list[dict[str, Any]]
execution_decision: dict[str, Any] | None
merge_report: dict[str, Any] | None
verification_report: dict[str, Any] | None
feature_flags: dict[str, bool]
observability_report: dict[str, Any] | None
turn_context: dict[str, Any] | None turn_context: dict[str, Any] | None
routing_decision: dict[str, Any] | None routing_decision: dict[str, Any] | None
@@ -254,6 +266,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
memory_context=None, memory_context=None,
current_datetime_context=None, current_datetime_context=None,
current_datetime_reference=None, current_datetime_reference=None,
runtime_request_context=None,
task_graph=None,
scheduled_subtasks=[],
recalled_retrospectives=[],
retrospective_shortlist=[],
skill_shortlist=[],
skill_activation_records=[],
execution_decision=None,
merge_report=None,
verification_report=None,
feature_flags={},
observability_report=None,
turn_context=None, turn_context=None,
routing_decision=None, routing_decision=None,
continuity_state=None, continuity_state=None,

View File

@@ -104,6 +104,15 @@ class Settings(BaseSettings):
WEB_SEARCH_DEFAULT_LIMIT: int = 5 WEB_SEARCH_DEFAULT_LIMIT: int = 5
WEB_SEARCH_TIMEOUT_SECONDS: int = 10 WEB_SEARCH_TIMEOUT_SECONDS: int = 10
# === Hermes 风格升级开关 ===
ENABLE_RETROSPECTIVE: bool = True
ENABLE_SESSION_RETROSPECTIVE_SEARCH: bool = True
ENABLE_RUNTIME_SKILL_SHORTLIST: bool = True
ENABLE_LEARNING_SIGNALS: bool = True
ENABLE_SKILL_PROMOTION: bool = True
ENABLE_LEARNED_SKILL_LOADING: bool = True
ENABLE_PARALLEL_TASK_GRAPH: bool = True
settings = Settings() settings = Settings()
settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1) settings.DATABASE_URL = settings.DATABASE_URL.replace("./data", _resolve_path("./data"), 1)

View File

@@ -39,10 +39,12 @@ async def init_db():
await ensure_message_columns(conn) await ensure_message_columns(conn)
await ensure_conversation_columns(conn) await ensure_conversation_columns(conn)
await ensure_document_columns(conn) await ensure_document_columns(conn)
await ensure_memory_columns(conn)
await ensure_user_columns(conn) await ensure_user_columns(conn)
await ensure_forum_columns(conn) await ensure_forum_columns(conn)
await ensure_agent_columns(conn) await ensure_agent_columns(conn)
await ensure_skill_columns(conn) await ensure_skill_columns(conn)
await ensure_learning_artifact_tables(conn)
async def ensure_log_columns(conn): async def ensure_log_columns(conn):
@@ -115,6 +117,28 @@ async def ensure_document_columns(conn):
await conn.execute(text(ddl)) await conn.execute(text(ddl))
async def ensure_memory_columns(conn):
rows = await _get_table_info(conn, 'user_memories')
if not rows:
return
columns = {row[1] for row in rows}
required_columns = {
'frequency_count': "ALTER TABLE user_memories ADD COLUMN frequency_count INTEGER DEFAULT 0",
'emotion_tags': "ALTER TABLE user_memories ADD COLUMN emotion_tags JSON",
'importance_score': "ALTER TABLE user_memories ADD COLUMN importance_score FLOAT DEFAULT 0.5",
'importance_level': "ALTER TABLE user_memories ADD COLUMN importance_level VARCHAR(20) DEFAULT 'medium'",
'associated_topics': "ALTER TABLE user_memories ADD COLUMN associated_topics JSON",
'decay_score': "ALTER TABLE user_memories ADD COLUMN decay_score FLOAT DEFAULT 1.0",
'is_archived': "ALTER TABLE user_memories ADD COLUMN is_archived BOOLEAN DEFAULT 0",
'last_accessed_at': "ALTER TABLE user_memories ADD COLUMN last_accessed_at DATETIME",
'archive_at': "ALTER TABLE user_memories ADD COLUMN archive_at DATETIME",
}
for column, ddl in required_columns.items():
if column not in columns:
await conn.execute(text(ddl))
async def ensure_user_columns(conn): async def ensure_user_columns(conn):
rows = await _get_table_info(conn, 'users') rows = await _get_table_info(conn, 'users')
if not rows: if not rows:
@@ -181,6 +205,14 @@ async def ensure_skill_columns(conn):
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT", 'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL", 'is_builtin': "ALTER TABLE skills ADD COLUMN is_builtin BOOLEAN DEFAULT 0 NOT NULL",
'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)", 'team_id': "ALTER TABLE skills ADD COLUMN team_id VARCHAR(36)",
'status': "ALTER TABLE skills ADD COLUMN status VARCHAR(20) DEFAULT 'active' NOT NULL",
'scope': "ALTER TABLE skills ADD COLUMN scope JSON DEFAULT '[]' NOT NULL",
'effectiveness': "ALTER TABLE skills ADD COLUMN effectiveness FLOAT DEFAULT 0.0 NOT NULL",
'review_after': "ALTER TABLE skills ADD COLUMN review_after DATETIME",
'candidate_count': "ALTER TABLE skills ADD COLUMN candidate_count INTEGER DEFAULT 0 NOT NULL",
'candidate_source_hashes': "ALTER TABLE skills ADD COLUMN candidate_source_hashes JSON DEFAULT '[]' NOT NULL",
'activation_count': "ALTER TABLE skills ADD COLUMN activation_count INTEGER DEFAULT 0 NOT NULL",
'last_activated_at': "ALTER TABLE skills ADD COLUMN last_activated_at DATETIME",
} }
for column, ddl in required_columns.items(): for column, ddl in required_columns.items():
if column not in columns: if column not in columns:
@@ -205,6 +237,48 @@ async def ensure_skill_columns(conn):
) )
async def ensure_learning_artifact_tables(conn):
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS learning_artifacts (
id VARCHAR(36) PRIMARY KEY,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
user_id VARCHAR(36) NOT NULL,
conversation_id VARCHAR(36) NOT NULL,
retrospective_id VARCHAR(36),
artifact_type VARCHAR(32) NOT NULL,
artifact_key VARCHAR(128),
summary_text TEXT NOT NULL,
payload JSON NOT NULL,
recorded_at DATETIME NOT NULL
)
"""
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_user_id ON learning_artifacts (user_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_conversation_id ON learning_artifacts (conversation_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_retrospective_id ON learning_artifacts (retrospective_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_learning_artifacts_artifact_type ON learning_artifacts (artifact_type)"
)
)
async def _backfill_usernames(conn): async def _backfill_usernames(conn):
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id")) result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
users = result.fetchall() users = result.fetchall()

View File

@@ -7,6 +7,7 @@ from app.models.forum import ForumPost, ForumReply
from app.models.agent import Agent, AgentMessage from app.models.agent import Agent, AgentMessage
from app.models.conversation import Conversation, Message from app.models.conversation import Conversation, Message
from app.models.knowledge_graph import KGNode, KGEdge from app.models.knowledge_graph import KGNode, KGEdge
from app.models.learning import LearningArtifactRecord, SessionRetrospectiveRecord
from app.models.memory import MemorySummary, UserMemory from app.models.memory import MemorySummary, UserMemory
from app.models.brain import ( from app.models.brain import (
BrainEvent, BrainEvent,
@@ -20,6 +21,7 @@ from app.models.brain import (
from app.models.todo import DailyTodo, TodoSource from app.models.todo import DailyTodo, TodoSource
from app.models.reminder import Reminder, ReminderStatus from app.models.reminder import Reminder, ReminderStatus
from app.models.goal import Goal, GoalStatus from app.models.goal import Goal, GoalStatus
from app.models.skill import Skill
from app.models.log import Log, LogType, LogLevel from app.models.log import Log, LogType, LogLevel
__all__ = [ __all__ = [
@@ -38,6 +40,8 @@ __all__ = [
"Message", "Message",
"KGNode", "KGNode",
"KGEdge", "KGEdge",
"LearningArtifactRecord",
"SessionRetrospectiveRecord",
"MemorySummary", "MemorySummary",
"UserMemory", "UserMemory",
"BrainEvent", "BrainEvent",
@@ -53,6 +57,7 @@ __all__ = [
"ReminderStatus", "ReminderStatus",
"Goal", "Goal",
"GoalStatus", "GoalStatus",
"Skill",
"Log", "Log",
"LogType", "LogType",
"LogLevel", "LogLevel",

View File

@@ -0,0 +1,38 @@
from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Text
from app.models.base import BaseModel, utc_now
class SessionRetrospectiveRecord(BaseModel):
__tablename__ = "session_retrospectives"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
request_message_id = Column(String(36), ForeignKey("messages.id"), nullable=True, index=True)
response_message_id = Column(String(36), ForeignKey("messages.id"), nullable=True, index=True)
query_text = Column(Text, nullable=False)
final_response = Column(Text, nullable=True)
summary_text = Column(Text, nullable=False)
task_type = Column(String(64), nullable=True, index=True)
execution_mode = Column(String(32), nullable=True, index=True)
primary_agent = Column(String(64), nullable=True)
verification_status = Column(String(32), nullable=True)
verification_summary = Column(Text, nullable=True)
skill_names = Column(JSON, default=list, nullable=False)
evidence = Column(JSON, default=list, nullable=False)
task_refs = Column(JSON, default=list, nullable=False)
payload = Column(JSON, default=dict, nullable=False)
recorded_at = Column(DateTime, default=utc_now, nullable=False)
class LearningArtifactRecord(BaseModel):
__tablename__ = "learning_artifacts"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False, index=True)
retrospective_id = Column(String(36), ForeignKey("session_retrospectives.id"), nullable=True, index=True)
artifact_type = Column(String(32), nullable=False, index=True)
artifact_key = Column(String(128), nullable=True, index=True)
summary_text = Column(Text, nullable=False)
payload = Column(JSON, default=dict, nullable=False)
recorded_at = Column(DateTime, default=utc_now, nullable=False)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, Text, Boolean, JSON, ForeignKey from sqlalchemy import Column, String, Text, Boolean, JSON, ForeignKey, Float, Integer, DateTime
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models.base import BaseModel from app.models.base import BaseModel
@@ -17,6 +17,14 @@ class Skill(BaseModel):
is_builtin = Column(Boolean, default=False, nullable=False) is_builtin = Column(Boolean, default=False, nullable=False)
team_id = Column(String(36), ForeignKey("users.id"), nullable=True) team_id = Column(String(36), ForeignKey("users.id"), nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
status = Column(String(20), default="active", nullable=False, index=True) # candidate/shadow/active/deprecated/retired
scope = Column(JSON, default=list, nullable=False)
effectiveness = Column(Float, default=0.0, nullable=False)
review_after = Column(DateTime, nullable=True)
candidate_count = Column(Integer, default=0, nullable=False)
candidate_source_hashes = Column(JSON, default=list, nullable=False)
activation_count = Column(Integer, default=0, nullable=False)
last_activated_at = Column(DateTime, nullable=True)
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
owner = relationship("User", foreign_keys=[owner_id]) owner = relationship("User", foreign_keys=[owner_id])

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.agents.learning.store import LearningArtifactStore, SessionRetrospectiveStore
from app.agents.registry import load_builtin_registry_indexes from app.agents.registry import load_builtin_registry_indexes
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
from app.models.agent import Agent from app.models.agent import Agent
@@ -37,6 +38,7 @@ from app.schemas.agent import (
AgentVisibilityVerifierOut, AgentVisibilityVerifierOut,
) )
from app.services.agent_service import _extract_continuity_snapshot from app.services.agent_service import _extract_continuity_snapshot
from app.services.runtime_observability import build_runtime_observability_report
router = APIRouter(prefix="/api/agents", tags=["Agent"]) router = APIRouter(prefix="/api/agents", tags=["Agent"])
@@ -662,6 +664,59 @@ async def get_visibility_tools(
return _build_tool_governance(state, conversation_id=conversation_id) return _build_tool_governance(state, conversation_id=conversation_id)
@router.get("/visibility/debug")
async def get_visibility_debug(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
observability = build_runtime_observability_report(
state=state,
feature_flags=dict(state.get("feature_flags") or {}),
)
retrospective_store = SessionRetrospectiveStore(db)
artifact_store = LearningArtifactStore(db)
recent_retrospectives = await retrospective_store.list_recent(
user_id=current_user.id,
limit=5,
)
recent_artifacts = await artifact_store.list_recent(
user_id=current_user.id,
limit=10,
)
return {
"conversation_id": conversation_id,
"observability": observability,
"skill_shortlist": list(state.get("skill_shortlist") or []),
"retrospective_shortlist": list(state.get("retrospective_shortlist") or []),
"merge_report": state.get("merge_report"),
"verification_report": state.get("verification_report"),
"recent_retrospectives": [
{
"id": item.id,
"task_type": item.task_type,
"summary": item.summary_text,
"execution_mode": item.execution_mode,
"verification_status": item.verification_status,
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
}
for item in recent_retrospectives
],
"recent_learning_artifacts": [
{
"id": item.id,
"artifact_type": item.artifact_type,
"artifact_key": item.artifact_key,
"summary": item.summary_text,
"recorded_at": item.recorded_at.isoformat() if item.recorded_at else None,
}
for item in recent_artifacts
],
}
@router.post("", response_model=AgentOut, status_code=201) @router.post("", response_model=AgentOut, status_code=201)
async def create_agent( async def create_agent(
data: AgentCreate, data: AgentCreate,

View File

@@ -145,6 +145,9 @@ async def chat_stream(
except ValueError as exc: except ValueError as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n" yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
return return
except Exception as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
return
yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n" yield f"event: metadata\ndata: {json.dumps({'conversation_id': conv_id, 'message_id': msg_id})}\n\n"

View File

@@ -29,6 +29,10 @@ async def create_skill(
visibility=data.visibility, visibility=data.visibility,
team_id=data.team_id, team_id=data.team_id,
is_active=data.is_active, is_active=data.is_active,
status=data.status,
scope=data.scope,
effectiveness=data.effectiveness,
review_after=data.review_after,
owner_id=current_user.id, owner_id=current_user.id,
) )
db.add(skill) db.add(skill)
@@ -103,6 +107,14 @@ async def update_skill(
skill.team_id = data.team_id skill.team_id = data.team_id
if data.is_active is not None: if data.is_active is not None:
skill.is_active = data.is_active skill.is_active = data.is_active
if data.status is not None:
skill.status = data.status
if data.scope is not None:
skill.scope = data.scope
if data.effectiveness is not None:
skill.effectiveness = data.effectiveness
if data.review_after is not None:
skill.review_after = data.review_after
await db.commit() await db.commit()
await db.refresh(skill) await db.refresh(skill)

View File

@@ -12,4 +12,4 @@ async def get_system_status():
@router.get('/config') @router.get('/config')
async def get_system_config(): async def get_system_config():
"""Get public system configuration.""" """Get public system configuration."""
return SystemService().get_config() return await SystemService().get_config()

View File

@@ -14,6 +14,10 @@ class SkillCreate(BaseModel):
visibility: str = "private" visibility: str = "private"
team_id: Optional[str] = None team_id: Optional[str] = None
is_active: bool = True is_active: bool = True
status: str = "active"
scope: list[str] = []
effectiveness: Optional[float] = None
review_after: Optional[datetime] = None
class SkillUpdate(BaseModel): class SkillUpdate(BaseModel):
@@ -28,6 +32,10 @@ class SkillUpdate(BaseModel):
visibility: Optional[str] = None visibility: Optional[str] = None
team_id: Optional[str] = None team_id: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
status: Optional[str] = None
scope: Optional[list[str]] = None
effectiveness: Optional[float] = None
review_after: Optional[datetime] = None
class SkillOut(BaseModel): class SkillOut(BaseModel):
@@ -43,6 +51,12 @@ class SkillOut(BaseModel):
is_builtin: bool is_builtin: bool
team_id: Optional[str] team_id: Optional[str]
is_active: bool is_active: bool
status: str
scope: list[str]
effectiveness: Optional[float]
review_after: Optional[datetime]
activation_count: int
last_activated_at: Optional[datetime]
owner_id: str owner_id: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -7,12 +7,13 @@ import json
import uuid import uuid
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from time import perf_counter
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
import asyncio import asyncio
from openai import BadRequestError from openai import BadRequestError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from langchain_core.messages import HumanMessage, AIMessage from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from app.database import async_session from app.database import async_session
from app.logging_utils import summarize_llm_config from app.logging_utils import summarize_llm_config
@@ -21,10 +22,24 @@ from app.models.conversation import Conversation, Message
from app.models.user import User from app.models.user import User
from app.agents.graph import get_agent_graph from app.agents.graph import get_agent_graph
from app.agents.context import set_current_user, clear_current_user from app.agents.context import set_current_user, clear_current_user
from app.agents.learning.jobs import schedule_retrospective_job
from app.agents.learning.retrospector import build_session_retrospective
from app.agents.learning.session_search import SessionRetrospectiveSearch, summarize_retrospective
from app.agents.orchestration.task_graph import build_bounded_task_graph
from app.agents.learning.store import append_retrospective_attachment
from app.agents.schemas.orchestration import (
RuntimeRequestContext,
assess_parallel_worthiness,
render_runtime_request_context_summary,
)
from app.agents.schemas.skills import SkillActivationRecord
from app.agents.skills.registry import get_skill_registry from app.agents.skills.registry import get_skill_registry
from app.agents.skills.retriever import shortlist_skills_for_request
from app.services import memory_service from app.services import memory_service
from app.services.brain_service import BrainService from app.services.brain_service import BrainService
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
from app.services.rollback_controller import RollbackController
from app.services.runtime_observability import build_runtime_observability_report
from app.agents.tools.time_reasoning import extract_reference_datetime from app.agents.tools.time_reasoning import extract_reference_datetime
from app.agents.state import initial_state from app.agents.state import initial_state
@@ -36,6 +51,7 @@ MEMORY_SECTION_HEADERS = (
"【之前对话摘要】", "【之前对话摘要】",
"【知识大脑】", "【知识大脑】",
) )
MEMORY_INLINE_HEADERS = {"[关于你的记忆]"}
def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]: def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]:
@@ -81,6 +97,41 @@ def _derive_role_memory_contexts(memory_context: str | None) -> dict[str, str |
} }
def _extract_memory_highlights(memory_context: str | None, *, limit: int = 5) -> list[str]:
text = (memory_context or "").strip()
if not text:
return []
highlights: list[str] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line in MEMORY_SECTION_HEADERS or line in MEMORY_INLINE_HEADERS:
continue
if line.startswith("-"):
normalized = line[1:].strip()
else:
normalized = line
if normalized:
highlights.append(normalized)
if len(highlights) >= limit:
break
return highlights
def _summarize_retrospective(retrospective: Any) -> str:
summary = str(getattr(retrospective, "summary", "") or "").strip()
task_type = str(getattr(retrospective, "task_type", "") or "").strip()
execution_mode = str(getattr(retrospective, "execution_mode", "") or "").strip()
outcome = str(getattr(retrospective, "outcome", "") or "").strip()
parts = [summary[:80] or task_type or "历史复盘"]
if execution_mode:
parts.append(f"mode={execution_mode}")
if outcome:
parts.append(f"outcome={outcome}")
return "".join(parts)
def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool: def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None) -> bool:
capabilities = resolve_provider_capabilities(user_llm_config) capabilities = resolve_provider_capabilities(user_llm_config)
error_text = str(error).lower() error_text = str(error).lower()
@@ -461,18 +512,27 @@ class AgentService:
async def _build_agent_state( async def _build_agent_state(
self, self,
*, *,
request_id: str,
user_id: str, user_id: str,
conversation: Conversation, conversation: Conversation,
raw_user_query: str,
full_message: str, full_message: str,
memory_context: str | None, memory_context: str | None,
current_datetime_context: str, current_datetime_context: str,
current_datetime_reference: dict[str, str], current_datetime_reference: dict[str, str],
user_llm_config: dict | None, user_llm_config: dict | None,
runtime_request_context: RuntimeRequestContext,
recalled_retrospectives: list[dict[str, Any]],
skill_shortlist: list[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
state = initial_state(user_id, conversation.id) state = initial_state(user_id, conversation.id)
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
state.update( state.update(
{ {
"messages": [HumanMessage(content=full_message)], "messages": [
SystemMessage(content=runtime_summary),
HumanMessage(content=full_message),
],
"memory_context": memory_context, "memory_context": memory_context,
"current_datetime_context": current_datetime_context, "current_datetime_context": current_datetime_context,
"current_datetime_reference": current_datetime_reference, "current_datetime_reference": current_datetime_reference,
@@ -482,9 +542,119 @@ class AgentService:
previous_snapshot = await self._load_continuity_snapshot(conversation) previous_snapshot = await self._load_continuity_snapshot(conversation)
if previous_snapshot: if previous_snapshot:
state.update(previous_snapshot) state.update(previous_snapshot)
state["messages"] = [HumanMessage(content=full_message)] state["messages"] = [
SystemMessage(content=runtime_summary),
HumanMessage(content=full_message),
]
state.update(
{
"runtime_request_context": runtime_request_context.model_dump(mode="json"),
"task_graph": (
runtime_request_context.task_graph.model_dump(mode="json")
if runtime_request_context.task_graph is not None
else None
),
"feature_flags": RollbackController().snapshot_flags(),
"recalled_retrospectives": recalled_retrospectives,
"retrospective_shortlist": recalled_retrospectives,
"skill_shortlist": skill_shortlist,
"skill_activation_records": [
SkillActivationRecord(
skill_name=item.get("skill_name"),
source=item.get("source", "runtime"),
source_id=item.get("source_id"),
status=item.get("status", "active"),
injection_mode=item.get("injection_mode", "metadata_only"),
matched_terms=item.get("matched_terms", []),
rationale=item.get("rationale"),
).model_dump(mode="json")
for item in skill_shortlist
if item.get("skill_name")
],
"parallel_worthiness": runtime_request_context.parallel_worthiness.model_dump(
mode="json"
),
}
)
return state return state
async def _build_runtime_request_context(
self,
*,
request_id: str,
user_id: str,
conversation: Conversation,
user_query: str,
memory_context: str | None,
) -> tuple[RuntimeRequestContext, list[dict[str, Any]], list[dict[str, Any]]]:
started_at = perf_counter()
retrospectives_started = perf_counter()
recent_retrospectives = await SessionRetrospectiveSearch(self.db).shortlist(
user_id=user_id,
query_text=user_query,
conversation_id=conversation.id,
limit=3,
)
retrospective_ms = (perf_counter() - retrospectives_started) * 1000
feature_flags = RollbackController().snapshot_flags()
shortlist_started = perf_counter()
skill_shortlist = await shortlist_skills_for_request(
self.db,
user_id=user_id,
user_query=user_query,
memory_context=memory_context,
retrospectives=[item.model_dump(mode="json") for item in recent_retrospectives],
include_learned=feature_flags["ENABLE_LEARNED_SKILL_LOADING"],
limit=4,
)
skill_shortlist_ms = (perf_counter() - shortlist_started) * 1000
parallel_worthiness = assess_parallel_worthiness(
user_query,
retrospective_count=len(recent_retrospectives),
skill_count=len(skill_shortlist),
)
recommended_runtime_mode = (
"collaboration" if parallel_worthiness.preferred_mode != "direct" else "direct"
)
task_graph = (
build_bounded_task_graph(
query_text=user_query,
parallel_worthiness=parallel_worthiness,
)
if feature_flags["ENABLE_PARALLEL_TASK_GRAPH"]
else None
)
runtime_request_context = RuntimeRequestContext(
request_id=request_id,
session_id=conversation.id,
conversation_id=conversation.id,
user_id=user_id,
query_text=user_query,
raw_user_query=user_query,
recalled_memories=_extract_memory_highlights(memory_context),
recalled_retrospectives=[
summarize_retrospective(retrospective) for retrospective in recent_retrospectives
],
shortlisted_skills=[entry.skill_name for entry in skill_shortlist],
skill_shortlist=skill_shortlist,
current_agent_role="master",
execution_mode=recommended_runtime_mode,
conversation_state_ref=conversation.id,
parallel_worthiness=parallel_worthiness,
task_graph=task_graph,
recommended_runtime_mode=recommended_runtime_mode,
assembly_metrics={
"retrospective_ms": round(retrospective_ms, 3),
"skill_shortlist_ms": round(skill_shortlist_ms, 3),
"total_ms": round((perf_counter() - started_at) * 1000, 3),
},
)
return (
runtime_request_context,
[item.model_dump(mode="json") for item in recent_retrospectives],
[item.model_dump(mode="json") for item in skill_shortlist],
)
async def chat( async def chat(
self, self,
user_id: str, user_id: str,
@@ -610,21 +780,38 @@ class AgentService:
async def run_agent(): async def run_agent():
collected = "" collected = ""
state: dict[str, Any] | None = None state: dict[str, Any] | None = None
runtime_request_context: RuntimeRequestContext | None = None
set_current_user(user_id) set_current_user(user_id)
try: try:
graph = get_agent_graph() graph = get_agent_graph()
current_datetime_context, current_datetime_reference = ( current_datetime_context, current_datetime_reference = (
self._build_current_datetime_context() self._build_current_datetime_context()
) )
(
state = await self._build_agent_state( runtime_request_context,
recalled_retrospectives,
skill_shortlist,
) = await self._build_runtime_request_context(
request_id=assistant_msg.id,
user_id=user_id, user_id=user_id,
conversation=conv, conversation=conv,
user_query=message,
memory_context=memory_ctx,
)
state = await self._build_agent_state(
request_id=assistant_msg.id,
user_id=user_id,
conversation=conv,
raw_user_query=message,
full_message=full_message, full_message=full_message,
memory_context=memory_ctx, memory_context=memory_ctx,
current_datetime_context=current_datetime_context, current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference, current_datetime_reference=current_datetime_reference,
user_llm_config=user_llm_config, user_llm_config=user_llm_config,
runtime_request_context=runtime_request_context,
recalled_retrospectives=recalled_retrospectives,
skill_shortlist=skill_shortlist,
) )
state.update(_derive_role_memory_contexts(memory_ctx)) state.update(_derive_role_memory_contexts(memory_ctx))
@@ -749,7 +936,7 @@ class AgentService:
if collected: if collected:
assistant_msg.content = collected assistant_msg.content = collected
continuity_snapshot = _build_continuity_snapshot(state or {}) continuity_snapshot = _build_continuity_snapshot(state or {})
assistant_msg.attachments = ( attachments = (
[ [
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -757,8 +944,26 @@ class AgentService:
} }
] ]
if continuity_snapshot if continuity_snapshot
else None else []
) )
if state is not None and runtime_request_context is not None:
retrospective = build_session_retrospective(
request_id=assistant_msg.id,
session_id=conversation_id,
user_query=message,
state=state,
runtime_context=runtime_request_context,
)
attachments = append_retrospective_attachment(attachments, retrospective)
attachments.append(
{
"kind": "runtime_observability",
"payload": build_runtime_observability_report(
state=state,
feature_flags=state.get("feature_flags") or {},
),
}
)
conv.agent_state = ( conv.agent_state = (
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -771,8 +976,18 @@ class AgentService:
user_id, user_id,
**_build_assistant_event_payload(collected), **_build_assistant_event_payload(collected),
) )
assistant_msg.attachments = attachments or None
await self.db.commit() await self.db.commit()
await self.db.refresh(assistant_msg) await self.db.refresh(assistant_msg)
schedule_retrospective_job(
user_id=user_id,
conversation_id=conversation_id,
request_message_id=user_msg.id,
response_message_id=assistant_msg.id,
query_text=message,
final_response=collected,
state=state,
)
except Exception: except Exception:
logger.exception("save_assistant_message_failed") logger.exception("save_assistant_message_failed")
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id)) asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
@@ -863,14 +1078,30 @@ class AgentService:
current_datetime_context, current_datetime_reference = ( current_datetime_context, current_datetime_reference = (
self._build_current_datetime_context() self._build_current_datetime_context()
) )
state = await self._build_agent_state( (
runtime_request_context,
recalled_retrospectives,
skill_shortlist,
) = await self._build_runtime_request_context(
request_id=assistant_msg.id,
user_id=user_id, user_id=user_id,
conversation=conv, conversation=conv,
user_query=message,
memory_context=memory_ctx,
)
state = await self._build_agent_state(
request_id=assistant_msg.id,
user_id=user_id,
conversation=conv,
raw_user_query=message,
full_message=message, full_message=message,
memory_context=memory_ctx, memory_context=memory_ctx,
current_datetime_context=current_datetime_context, current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference, current_datetime_reference=current_datetime_reference,
user_llm_config=user_llm_config, user_llm_config=user_llm_config,
runtime_request_context=runtime_request_context,
recalled_retrospectives=recalled_retrospectives,
skill_shortlist=skill_shortlist,
) )
state.update(_derive_role_memory_contexts(memory_ctx)) state.update(_derive_role_memory_contexts(memory_ctx))
result_state = await graph.ainvoke(state) result_state = await graph.ainvoke(state)
@@ -900,7 +1131,7 @@ class AgentService:
continuity_snapshot = ( continuity_snapshot = (
_build_continuity_snapshot(result_state) if "result_state" in locals() else None _build_continuity_snapshot(result_state) if "result_state" in locals() else None
) )
assistant_msg.attachments = ( attachments = (
[ [
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -908,8 +1139,26 @@ class AgentService:
} }
] ]
if continuity_snapshot if continuity_snapshot
else None else []
) )
if "result_state" in locals() and "runtime_request_context" in locals():
retrospective = build_session_retrospective(
request_id=assistant_msg.id,
session_id=conversation_id,
user_query=message,
state=result_state,
runtime_context=runtime_request_context,
)
attachments = append_retrospective_attachment(attachments, retrospective)
attachments.append(
{
"kind": "runtime_observability",
"payload": build_runtime_observability_report(
state=result_state,
feature_flags=result_state.get("feature_flags") or {},
),
}
)
conv.agent_state = ( conv.agent_state = (
{ {
"kind": "agent_continuity_state", "kind": "agent_continuity_state",
@@ -918,7 +1167,17 @@ class AgentService:
if continuity_snapshot if continuity_snapshot
else None else None
) )
assistant_msg.attachments = attachments or None
await self.db.commit() await self.db.commit()
await self.db.refresh(assistant_msg) await self.db.refresh(assistant_msg)
schedule_retrospective_job(
user_id=user_id,
conversation_id=conversation_id,
request_message_id=user_msg.id,
response_message_id=assistant_msg.id,
query_text=message,
final_response=response_content,
state=result_state if "result_state" in locals() else None,
)
return conversation_id, assistant_msg.id, response_content, model_name_used return conversation_id, assistant_msg.id, response_content, model_name_used

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from app.config import settings
FEATURE_FLAG_NAMES = (
"ENABLE_RETROSPECTIVE",
"ENABLE_SESSION_RETROSPECTIVE_SEARCH",
"ENABLE_RUNTIME_SKILL_SHORTLIST",
"ENABLE_LEARNING_SIGNALS",
"ENABLE_SKILL_PROMOTION",
"ENABLE_LEARNED_SKILL_LOADING",
"ENABLE_PARALLEL_TASK_GRAPH",
)
class RollbackController:
def snapshot_flags(self) -> dict[str, bool]:
return {
flag_name: bool(getattr(settings, flag_name, False))
for flag_name in FEATURE_FLAG_NAMES
}
def is_enabled(self, flag_name: str) -> bool:
return bool(getattr(settings, flag_name, False))

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.agents.orchestration.monitor import build_parallel_runtime_metrics
def build_runtime_observability_report(
*,
state: dict[str, Any],
feature_flags: dict[str, bool] | None = None,
) -> dict[str, Any]:
task_graph = state.get("task_graph") if isinstance(state.get("task_graph"), dict) else None
scheduled_subtasks = (
state.get("scheduled_subtasks") if isinstance(state.get("scheduled_subtasks"), list) else []
)
task_results = state.get("task_results") if isinstance(state.get("task_results"), list) else []
merge_report = state.get("merge_report") if isinstance(state.get("merge_report"), dict) else None
return {
"execution_mode": state.get("execution_mode"),
"verification_status": state.get("verification_status"),
"skill_shortlist_count": len(state.get("skill_shortlist") or []),
"retrospective_shortlist_count": len(state.get("retrospective_shortlist") or []),
"feature_flags": feature_flags or {},
"parallel_metrics": build_parallel_runtime_metrics(
task_graph=task_graph,
scheduled_subtasks=scheduled_subtasks,
task_results=task_results,
merge_report=merge_report,
),
}

View File

@@ -3,9 +3,13 @@ Skill Service - 技能管理服务层
负责技能的创建、查询、更新、删除等操作 负责技能的创建、查询、更新、删除等操作
""" """
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Optional from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_ from sqlalchemy import select, and_, or_
from app.agents.schemas.learning import SkillCandidate
from app.agents.skills.models import SkillLifecycleDecision
from app.models.skill import Skill from app.models.skill import Skill
from app.models.user import User from app.models.user import User
@@ -28,6 +32,10 @@ class SkillService:
visibility=data.get("visibility", "private"), visibility=data.get("visibility", "private"),
team_id=data.get("team_id"), team_id=data.get("team_id"),
is_active=data.get("is_active", True), is_active=data.get("is_active", True),
status=data.get("status", "active"),
scope=data.get("scope", []),
effectiveness=data.get("effectiveness", 0.0),
review_after=data.get("review_after"),
) )
self.db.add(skill) self.db.add(skill)
await self.db.commit() await self.db.commit()
@@ -41,6 +49,17 @@ class SkillService:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
result = await self.db.execute(
select(Skill).where(and_(Skill.name == name, access_scope))
)
return result.scalar_one_or_none()
async def list_for_user( async def list_for_user(
self, self,
user_id: str, user_id: str,
@@ -56,7 +75,7 @@ class SkillService:
Skill.team_id == user_id, Skill.team_id == user_id,
) )
filters = [access_scope, Skill.is_active == True] filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
if agent_type: if agent_type:
filters.append(Skill.agent_type == agent_type) filters.append(Skill.agent_type == agent_type)
@@ -83,7 +102,7 @@ class SkillService:
update_fields = [ update_fields = [
"name", "description", "instructions", "agent_type", "name", "description", "instructions", "agent_type",
"tools", "required_context", "output_format", "visibility", "tools", "required_context", "output_format", "visibility",
"team_id", "is_active" "team_id", "is_active", "status", "scope", "effectiveness", "review_after"
] ]
for field in update_fields: for field in update_fields:
@@ -117,6 +136,7 @@ class SkillService:
and_( and_(
Skill.agent_type == agent_type, Skill.agent_type == agent_type,
Skill.is_active == True, Skill.is_active == True,
Skill.status == "active",
or_( or_(
Skill.visibility == "market", Skill.visibility == "market",
Skill.visibility == "private" Skill.visibility == "private"
@@ -125,3 +145,234 @@ class SkillService:
) )
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def list_runtime_candidates(
self,
user_id: str,
*,
agent_type: Optional[str] = None,
include_shadow: bool = True,
include_learned: bool = True,
) -> list[Skill]:
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
filters = [
access_scope,
Skill.is_active == True,
Skill.status.in_(allowed_statuses),
]
if not include_learned:
filters.append(Skill.is_builtin == True)
if agent_type:
filters.append(Skill.agent_type == agent_type)
result = await self.db.execute(select(Skill).where(and_(*filters)))
return list(result.scalars().all())
async def upsert_learned_candidate(
self,
*,
user_id: str,
candidate: SkillCandidate,
primary_agent: str | None,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision:
source_hash = self._build_candidate_source_hash(candidate)
skill = await self.get_by_name_for_user(user_id, candidate.name)
if skill is None:
review_after = datetime.now(UTC) + timedelta(days=7)
skill = Skill(
owner_id=user_id,
name=candidate.name,
description=candidate.summary,
instructions=candidate.summary,
agent_type=primary_agent or "master",
tools=[],
required_context=[],
output_format=None,
visibility="private",
is_active=True,
status="candidate",
scope=[primary_agent or "master", "learned", candidate.candidate_type],
effectiveness=candidate.confidence,
review_after=review_after,
candidate_count=1,
candidate_source_hashes=[source_hash],
)
self.db.add(skill)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action="created_candidate",
previous_status=None,
new_status="candidate",
reason="First learned candidate created from retrospective evidence.",
evidence_refs=evidence_refs or [],
confidence=candidate.confidence,
review_after=review_after,
)
previous_status = skill.status
known_hashes = list(skill.candidate_source_hashes or [])
is_duplicate_candidate = source_hash in known_hashes
if not is_duplicate_candidate:
skill.candidate_count = int(skill.candidate_count or 0) + 1
known_hashes.append(source_hash)
skill.candidate_source_hashes = known_hashes
current_effectiveness = float(skill.effectiveness or 0.0)
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
skill.review_after = datetime.now(UTC) + timedelta(days=7)
if primary_agent and primary_agent not in (skill.scope or []):
skill.scope = [*(skill.scope or []), primary_agent]
action = "no_change"
reason = "Candidate evidence refreshed."
if is_duplicate_candidate:
reason = "Duplicate candidate evidence ignored for promotion counting."
if (
not is_duplicate_candidate
and skill.status == "candidate"
and skill.candidate_count >= 2
and skill.effectiveness >= 0.6
):
skill.status = "shadow"
action = "promoted_to_shadow"
reason = "Repeated candidate evidence promoted the learned skill to shadow."
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def record_activation_feedback(
self,
*,
user_id: str,
skill_name: str,
outcome_score: float,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision | None:
skill = await self.get_by_name_for_user(user_id, skill_name)
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
return None
previous_status = skill.status
previous_activation_count = int(skill.activation_count or 0)
skill.activation_count = previous_activation_count + 1
skill.last_activated_at = datetime.now(UTC)
previous_effectiveness = float(skill.effectiveness or 0.0)
if previous_activation_count <= 0:
skill.effectiveness = round(outcome_score, 3)
else:
skill.effectiveness = round(
((previous_effectiveness * previous_activation_count) + outcome_score)
/ skill.activation_count,
3,
)
action = "feedback_recorded"
reason = "Activation outcome recorded."
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
skill.status = "active"
action = "promoted_to_active"
reason = "Shadow skill proved effective enough to become active."
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Active skill underperformed repeatedly and was deprecated."
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill stayed ineffective and was retired."
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
skill.status = "active"
action = "reactivated"
reason = "Deprecated skill recovered with strong positive feedback."
skill.review_after = datetime.now(UTC) + timedelta(days=7)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def run_decay_review(
self,
*,
user_id: str,
as_of: datetime | None = None,
) -> list[SkillLifecycleDecision]:
review_time = as_of or datetime.now(UTC)
result = await self.db.execute(
select(Skill).where(
and_(
Skill.owner_id == user_id,
Skill.is_active == True,
Skill.status.in_(["shadow", "active", "deprecated"]),
Skill.review_after.is_not(None),
Skill.review_after <= review_time,
)
)
)
skills = list(result.scalars().all())
decisions: list[SkillLifecycleDecision] = []
for skill in skills:
previous_status = skill.status
action = "no_change"
reason = "Review completed without status change."
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Shadow skill review found low effectiveness."
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill remained weak through review."
skill.review_after = review_time + timedelta(days=7)
decisions.append(
SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
confidence=skill.effectiveness,
review_after=skill.review_after,
)
)
await self.db.commit()
return decisions
@staticmethod
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
raw = (
f"{candidate.name}|{candidate.summary}|"
f"{','.join(candidate.source_pattern_ids)}|"
f"{len(candidate.evidence_refs)}"
).encode("utf-8")
return hashlib.sha1(raw).hexdigest()

View File

@@ -4,6 +4,9 @@ import os
import platform import platform
import socket import socket
import subprocess import subprocess
import time
import httpx
try: try:
import psutil import psutil
@@ -15,6 +18,10 @@ class SystemService:
_last_net_bytes_sent: int | None = None _last_net_bytes_sent: int | None = None
_last_net_bytes_recv: int | None = None _last_net_bytes_recv: int | None = None
_last_net_sample_at: float | None = None _last_net_sample_at: float | None = None
_weather_cache: dict | None = None
_weather_cached_at: float | None = None
_weather_cached_location: str | None = None
_weather_cache_ttl_seconds: float = 10 * 60 # 10 minutes
def __init__(self): def __init__(self):
# Import settings here to avoid circular imports # Import settings here to avoid circular imports
@@ -134,8 +141,95 @@ class SystemService:
'timestamp': datetime.now(UTC).isoformat(), 'timestamp': datetime.now(UTC).isoformat(),
} }
def get_config(self) -> dict: async def _fetch_weather(self, location: str) -> dict:
try:
timeout = httpx.Timeout(10.0, connect=5.0)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(f'https://wttr.in/{location}', params={'format': 'j1'})
response.raise_for_status()
payload = response.json()
current = (payload.get('current_condition') or [{}])[0]
weather_code = current.get('weatherCode')
temp = current.get('temp_C')
parsed_code = int(weather_code) if weather_code is not None and str(weather_code).isdigit() else None
if parsed_code is None or temp in (None, ''):
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
label = self._weather_code_label(parsed_code)
return {
'weather_code': parsed_code,
'weather_summary': f'{label} {temp}°C',
}
except (httpx.HTTPError, ValueError, TypeError):
return {'weather_code': None, 'weather_summary': 'Weather unavailable'}
@staticmethod
def _weather_code_label(code: int | None) -> str:
if code == 0:
return 'Clear'
if code in {1, 2}:
return 'Partly Cloudy'
if code == 3:
return 'Overcast'
if code in {45, 48}:
return 'Fog'
if code in {51, 53, 55, 56, 57}:
return 'Drizzle'
if code in {61, 63, 65, 66, 67, 80, 81, 82}:
return 'Rain'
if code in {71, 73, 75, 77, 85, 86}:
return 'Snow'
if code in {95, 96, 99}:
return 'Thunderstorm'
return 'Weather'
async def get_config(self) -> dict:
"""Get public system configuration.""" """Get public system configuration."""
location = self._settings.LOCATION
now = time.time()
cached_weather = self.__class__._weather_cache
cached_at = self.__class__._weather_cached_at
cached_location = self.__class__._weather_cached_location
cache_is_valid = (
cached_weather is not None
and cached_at is not None
and cached_location == location
and (now - cached_at) < self.__class__._weather_cache_ttl_seconds
)
if cache_is_valid:
return {
'location': location,
**cached_weather,
'weather_cached': True,
'weather_cached_at': cached_at,
}
weather = await self._fetch_weather(location)
# If fetch failed but we have *any* last known weather for same location, return it to avoid UI flicker.
if (
(weather.get('weather_code') is None)
and cached_weather is not None
and cached_location == location
):
return {
'location': location,
**cached_weather,
'weather_cached': True,
'weather_cached_at': cached_at,
'weather_stale': True,
}
# Update cache on successful/meaningful payload (or keep "unavailable" if never succeeded).
self.__class__._weather_cache = weather
self.__class__._weather_cached_at = now
self.__class__._weather_cached_location = location
return { return {
'location': self._settings.LOCATION, 'location': location,
**weather,
'weather_cached': False,
'weather_cached_at': now,
} }

View File

@@ -314,6 +314,22 @@ class FailIfCalledLLM:
raise AssertionError('LLM should not be called for simple greetings') raise AssertionError('LLM should not be called for simple greetings')
class InternalMarkupRecoveryLLM:
def __init__(self, responses: list[str]):
self.responses = responses
self.calls = 0
self._jarvis_provider_capabilities = SimpleNamespace(
provider='minimax',
supports_native_tools=False,
preferred_tool_strategy='json_fallback',
)
async def ainvoke(self, messages):
self.calls += 1
index = min(self.calls - 1, len(self.responses) - 1)
return AIMessage(content=self.responses[index])
def test_initial_state_sets_structured_continuity_defaults(): def test_initial_state_sets_structured_continuity_defaults():
state = initial_state('u1', 'c1') state = initial_state('u1', 'c1')
@@ -2047,6 +2063,75 @@ async def test_run_sub_commander_uses_web_search_in_json_fallback(monkeypatch):
assert result['final_response'] == '我查了外部网页,下面是最新结果摘要。' assert result['final_response'] == '我查了外部网页,下面是最新结果摘要。'
async def test_run_sub_commander_recovers_from_internal_tool_markup_after_tool_round(monkeypatch):
fake_llm = InternalMarkupRecoveryLLM([
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"武汉 介绍","top_k":2}}]}',
'我来让知识管理员为你整理武汉的详细介绍。\n\n分发说明:这个问题需要调用知识库信息,由 librarian知识管理员处理最合适。\n<minimax:tool_call>\n<invoke name="librarian">\n<parameter name="info_type">city_introduction</parameter>\n<parameter name="parameters">{"city":"武汉","word_count":2000,"language":"zh-CN"}</parameter>\n</invoke>\n</minimax:tool_call>',
'武汉是湖北省省会,位于长江与汉江交汇处,是中部重要的交通、科教和工业中心。',
])
fake_tool = FakeTool('web_search', 'found 2 web results')
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm)
monkeypatch.setitem(
__import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS,
'librarian_retrieval',
[fake_tool],
)
state = _base_state('请介绍一下武汉', {'provider': 'openai', 'model': 'MiniMax-M2.7-highspeed', 'base_url': 'https://api.minimaxi.com/v1'})
state['current_agent'] = AgentRole.LIBRARIAN
state['max_retries'] = 1
result = await _run_sub_commander(
state,
AgentRole.LIBRARIAN,
'manager prompt',
'请介绍一下武汉',
use_tools=True,
summary_target='knowledge_context',
)
assert fake_llm.calls == 3
assert fake_tool.invocations == [{'query': '武汉 介绍', 'top_k': 2}]
assert result['fallback_parse_error'] is None
assert '<invoke name=' not in result['final_response']
assert '分发说明' not in result['final_response']
assert result['final_response'] == '武汉是湖北省省会,位于长江与汉江交汇处,是中部重要的交通、科教和工业中心。'
async def test_run_sub_commander_falls_back_to_tool_summary_when_internal_markup_persists(monkeypatch):
fake_llm = InternalMarkupRecoveryLLM([
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"武汉 介绍","top_k":2}}]}',
'分发说明:交给 librarian。\n<minimax:tool_call><invoke name="librarian"></invoke></minimax:tool_call>',
])
fake_tool = FakeTool('web_search', 'found 2 web results')
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm)
monkeypatch.setitem(
__import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS,
'librarian_retrieval',
[fake_tool],
)
state = _base_state('请介绍一下武汉', {'provider': 'openai', 'model': 'MiniMax-M2.7-highspeed', 'base_url': 'https://api.minimaxi.com/v1'})
state['current_agent'] = AgentRole.LIBRARIAN
state['max_retries'] = 0
result = await _run_sub_commander(
state,
AgentRole.LIBRARIAN,
'manager prompt',
'请介绍一下武汉',
use_tools=True,
summary_target='knowledge_context',
)
assert fake_llm.calls == 2
assert result['fallback_parse_error'] == 'internal_tool_markup'
assert result['final_response'] == '我已经完成检索,直接给您可用信息:\n\nfound 2 web results'
assert '<invoke name=' not in result['final_response']
async def test_run_sub_commander_supports_multiple_json_fallback_tool_rounds(monkeypatch): async def test_run_sub_commander_supports_multiple_json_fallback_tool_rounds(monkeypatch):
fake_llm = TripleResponseFallbackLLM([ fake_llm = TripleResponseFallbackLLM([
'{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"Jarvis 最新模型更新","top_k":2}}]}', '{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"Jarvis 最新模型更新","top_k":2}}]}',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import app.agents.graph as graph_module
from app.agents.orchestration.result_merge import merge_task_results
from app.agents.schemas.task import AgentTask
from app.agents.state import AgentRole, initial_state
def test_merge_task_results_marks_conflict_for_distinct_completed_summaries():
report = merge_task_results(
[
{
"task_id": "task-1",
"status": "completed",
"summary": "结论 A",
"evidence": [{"type": "source"}],
"owner_agent_id": "librarian",
},
{
"task_id": "task-2",
"status": "completed",
"summary": "结论 B",
"evidence": [{"type": "analysis"}, {"type": "analysis"}],
"owner_agent_id": "analyst",
},
]
)
assert report.status == "conflicted"
assert "multiple_distinct_completed_summaries" in report.conflict_flags
assert report.resolution_strategy == "rank_by_evidence_count"
assert report.resolved_summary == "结论 B"
def test_verify_collaboration_results_persists_merge_and_verification_reports():
state = initial_state("u1", "c1")
tasks = [
AgentTask(
task_id="task-1",
title="收集证据",
role=AgentRole.LIBRARIAN.value,
owner_agent_id=AgentRole.LIBRARIAN.value,
goal="检索资料",
expected_evidence=[{"type": "evidence"}],
),
AgentTask(
task_id="task-2",
title="给出分析",
role=AgentRole.ANALYST.value,
owner_agent_id=AgentRole.ANALYST.value,
goal="分析风险",
expected_evidence=[{"type": "analysis"}],
),
]
graph_module._verify_collaboration_results(
state,
tasks,
task_results=[
{
"task_id": "task-1",
"status": "completed",
"summary": "证据显示风险中等",
"evidence": [{"type": "evidence"}],
"owner_agent_id": AgentRole.LIBRARIAN.value,
},
{
"task_id": "task-2",
"status": "completed",
"summary": "证据显示风险中等",
"evidence": [{"type": "analysis"}],
"owner_agent_id": AgentRole.ANALYST.value,
},
],
)
assert state["merge_report"] is not None
assert state["merge_report"]["status"] == "merged"
assert state["verification_report"] is not None
assert state["verification_report"]["status"] == "passed"
event_types = [item["event_type"] for item in state["event_trace"]]
assert "agent.merge.completed" in event_types
assert "agent.verify.completed" in event_types
def test_serial_fallback_response_is_used_when_merge_report_requires_fallback():
state = initial_state("u1", "c1")
tasks = [
AgentTask(
task_id="task-1",
title="收集证据",
role=AgentRole.LIBRARIAN.value,
owner_agent_id=AgentRole.LIBRARIAN.value,
goal="检索资料",
expected_evidence=[{"type": "evidence"}],
),
AgentTask(
task_id="task-2",
title="给出分析",
role=AgentRole.ANALYST.value,
owner_agent_id=AgentRole.ANALYST.value,
goal="分析风险",
expected_evidence=[{"type": "analysis"}],
),
]
state["task_results"] = [
{
"task_id": "task-1",
"status": "completed",
"summary": "已确认可用证据",
"evidence": [{"type": "evidence"}],
"owner_agent_id": AgentRole.LIBRARIAN.value,
},
{
"task_id": "task-2",
"status": "failed",
"summary": "分析失败",
"evidence": [{"type": "analysis"}],
"owner_agent_id": AgentRole.ANALYST.value,
},
]
state["final_response"] = "原始协作汇总"
graph_module._verify_collaboration_results(state, tasks, state["task_results"])
if state["verification_status"] == "failed" and state["merge_report"]["fallback_used"]:
state["final_response"] = graph_module._build_serial_fallback_response(
"先查资料再分析",
state["task_results"],
state["merge_report"],
)
graph_module._append_event_trace(
state,
"agent.rollback.triggered",
payload={"layer": "collaboration_runtime", "reason": "merge_fallback_used"},
severity="warning",
)
assert "切回保守收敛路径" in state["final_response"]
event_types = [item["event_type"] for item in state["event_trace"]]
assert "agent.rollback.triggered" in event_types

View File

@@ -0,0 +1,170 @@
from langchain_core.messages import HumanMessage
import app.agents.graph as graph_module
from app.agents.graph import master_node
from app.agents.learning.retrospector import build_session_retrospective
from app.agents.schemas.orchestration import (
RuntimeRequestContext,
assess_parallel_worthiness,
render_runtime_request_context_summary,
)
from app.agents.schemas.skills import SkillShortlistEntry
from app.agents.state import initial_state
def test_runtime_request_context_summary_renders_parallel_and_shortlists():
worthiness = assess_parallel_worthiness(
"先分析需求,再查资料,同时整理成计划",
retrospective_count=1,
skill_count=2,
)
context = RuntimeRequestContext(
user_id="u1",
session_id="c1",
query_text="先分析需求,再查资料,同时整理成计划",
recalled_memories=["最近偏好结构化输出"],
retrospective_shortlist=[
{
"task_type": "analysis",
"summary": "上次先检索再分析,结果更稳。",
}
],
shortlisted_skills=["weekly-planning"],
skill_shortlist=[
SkillShortlistEntry(
skill_name="weekly-planning",
score=0.82,
rationale="命中计划关键词",
injection_mode="summary",
)
],
parallel_worthiness=worthiness,
recommended_runtime_mode="collaboration",
)
summary = render_runtime_request_context_summary(context)
assert "Runtime Request Context" in summary
assert "collaboration" in summary
assert "weekly-planning" in summary
def test_build_session_retrospective_captures_skill_and_history_context():
retrospective = build_session_retrospective(
request_id="resp-1",
session_id="conv-1",
user_query="帮我分析并安排下周任务",
state={
"execution_mode": "collaboration",
"current_agent": "analyst",
"verification_status": "passed",
"verification_summary": "ok",
"final_response": "已经给出建议",
"skill_shortlist": [{"skill_name": "weekly-planning"}],
"event_trace": [{"event_type": "agent.execution.decided", "agent_id": "master"}],
"verification_evidence": [{"type": "verification"}],
"completed_tasks": [{"task_id": "t1", "title": "收集信息", "status": "completed"}],
"retrospective_shortlist": [{"summary": "上次周计划拆解有效"}],
"parallel_worthiness": {"score": 0.6},
},
runtime_context={
"user_id": "u1",
"recommended_runtime_mode": "collaboration",
},
)
assert retrospective.user_id == "u1"
assert retrospective.execution_mode == "collaboration"
assert retrospective.used_skill_names == ["weekly-planning"]
assert retrospective.context_snapshot["retrospective_shortlist_count"] == 1
assert retrospective.outcome == "completed"
async def test_master_node_records_execution_decision_and_skill_shortlist_event():
state = initial_state("u1", "c1")
state["messages"] = [HumanMessage(content="帮我查一下资料并分析重点")]
state["skill_shortlist"] = [
{
"skill_name": "research-synthesis",
"score": 0.73,
"injection_mode": "summary",
}
]
state["runtime_request_context"] = {
"request_id": "req-1",
"recommended_runtime_mode": "direct",
"parallel_worthiness": {
"preferred_mode": "direct",
"score": 0.2,
"estimated_subtasks": 1,
},
}
state["task_graph"] = {
"graph_id": "graph-1",
"nodes": [{"node_id": "task-1", "title": "收集证据", "role": "librarian"}],
"entry_node_ids": ["task-1"],
"max_parallelism": 1,
}
result = await master_node(state)
assert result["execution_decision"] is not None
event_types = [item["event_type"] for item in result["event_trace"]]
assert "agent.parallel.assessed" in event_types
assert "agent.skill.shortlisted" in event_types
assert "agent.task_graph.built" in event_types
assert "agent.execution.decided" in event_types
async def test_master_node_records_rollback_event_when_parallel_task_graph_flag_is_disabled():
async def fake_collaboration_flow(state, _user_query):
state["execution_mode"] = "collaboration"
state["final_response"] = "collaboration skipped in test"
return state
graph_module._run_collaboration_flow = fake_collaboration_flow
state = initial_state("u1", "c1")
state["messages"] = [HumanMessage(content="先查资料再分析风险再安排计划")]
state["feature_flags"] = {"ENABLE_PARALLEL_TASK_GRAPH": False}
state["parallel_worthiness"] = {
"preferred_mode": "parallel",
"score": 0.8,
"estimated_subtasks": 3,
}
state["runtime_request_context"] = {
"request_id": "req-2",
"user_id": "u1",
"session_id": "c1",
"recommended_runtime_mode": "collaboration",
}
result = await master_node(state)
event_types = [item["event_type"] for item in result["event_trace"]]
assert "agent.rollback.triggered" in event_types
async def test_master_node_direct_mode_baseline_still_returns_simple_response():
state = initial_state("u1", "c1")
state["messages"] = [HumanMessage(content="你好")]
result = await master_node(state)
assert result["execution_mode"] == "direct"
assert result["final_response"] is not None
async def test_master_node_collaboration_mode_baseline_still_respects_complex_request(monkeypatch):
async def fake_collaboration_flow(state, _user_query):
state["execution_mode"] = "collaboration"
state["final_response"] = "collaboration baseline ok"
return state
graph_module._run_collaboration_flow = fake_collaboration_flow
state = initial_state("u1", "c1")
state["messages"] = [HumanMessage(content="先查资料,再分析风险,再安排计划")]
result = await master_node(state)
assert result["execution_mode"] == "collaboration"
assert result["final_response"] == "collaboration baseline ok"

View File

@@ -0,0 +1,66 @@
from langchain_core.messages import HumanMessage
import app.agents.graph as graph_module
from app.agents.orchestration.scheduler import build_subtask_specs
from app.agents.orchestration.task_graph import build_bounded_task_graph
from app.agents.schemas.orchestration import TaskGraph, assess_parallel_worthiness
from app.agents.state import initial_state
def test_build_subtask_specs_keeps_dependencies_and_contract_fields():
worthiness = assess_parallel_worthiness(
"先查资料、再分析风险、再安排下周计划",
retrospective_count=2,
skill_count=1,
)
task_graph = build_bounded_task_graph(
query_text="先查资料、再分析风险、再安排下周计划",
parallel_worthiness=worthiness,
)
specs = build_subtask_specs(task_graph, query_text="先查资料、再分析风险、再安排下周计划")
assert specs
assert all(spec.parent_run_id == task_graph.graph_id for spec in specs)
assert all(isinstance(spec.context_slice, dict) for spec in specs)
assert all(spec.expected_output_schema for spec in specs)
assert any(spec.dependencies for spec in specs)
async def test_run_collaboration_flow_uses_task_graph_plan_and_records_subtask_events(monkeypatch):
async def fake_run_sub_commander(
state,
assigned_role,
_system_prompt,
task_goal,
**_kwargs,
):
state["final_response"] = f"{assigned_role.value} handled: {task_goal}"
return state
monkeypatch.setattr(graph_module, "_run_sub_commander", fake_run_sub_commander)
state = initial_state("u1", "c1")
state["messages"] = [HumanMessage(content="先查资料、再分析风险、再安排下周计划")]
state["current_datetime_context"] = "CURRENT_TIME: 2026-03-28T12:00:00+08:00"
state["task_graph"] = TaskGraph.model_validate(
build_bounded_task_graph(
query_text="先查资料、再分析风险、再安排下周计划",
parallel_worthiness=assess_parallel_worthiness(
"先查资料、再分析风险、再安排下周计划",
retrospective_count=2,
skill_count=1,
),
).model_dump(mode="json")
).model_dump(mode="json")
result = await graph_module._run_collaboration_flow(
state,
"先查资料、再分析风险、再安排下周计划",
)
assert result["scheduled_subtasks"]
event_types = [item["event_type"] for item in result["event_trace"]]
assert "agent.subtask.started" in event_types
assert "agent.subtask.completed" in event_types
assert result["task_results"]

View File

@@ -0,0 +1,59 @@
from app.agents.orchestration.task_graph import build_bounded_task_graph
from app.agents.schemas.orchestration import RuntimeRequestContext, assess_parallel_worthiness, render_runtime_request_context_summary
def test_build_bounded_task_graph_creates_independent_nodes_and_merge_node():
worthiness = assess_parallel_worthiness(
"先查资料、再分析风险、再安排下周计划",
retrospective_count=2,
skill_count=1,
)
graph = build_bounded_task_graph(
query_text="先查资料、再分析风险、再安排下周计划",
parallel_worthiness=worthiness,
)
assert graph is not None
assert len(graph.nodes) >= 2
assert graph.entry_node_ids
assert any(node.execution_mode == "parallel" for node in graph.nodes[:-1])
assert graph.nodes[-1].role == "master"
def test_runtime_request_context_summary_renders_task_graph():
worthiness = assess_parallel_worthiness(
"先查资料、再分析风险、再安排下周计划",
retrospective_count=1,
skill_count=1,
)
task_graph = build_bounded_task_graph(
query_text="先查资料、再分析风险、再安排下周计划",
parallel_worthiness=worthiness,
)
context = RuntimeRequestContext(
user_id="u1",
session_id="c1",
query_text="先查资料、再分析风险、再安排下周计划",
parallel_worthiness=worthiness,
task_graph=task_graph,
recommended_runtime_mode="collaboration",
)
summary = render_runtime_request_context_summary(context)
assert "任务图" in summary
assert "max_parallelism" in summary
def test_runtime_request_context_summary_renders_assembly_metrics():
context = RuntimeRequestContext(
user_id="u1",
session_id="c1",
query_text="帮我分析一下资料",
assembly_metrics={"total_ms": 12.3},
)
summary = render_runtime_request_context_summary(context)
assert "上下文装配耗时" in summary

View File

@@ -503,7 +503,9 @@ async def test_visibility_tools_returns_governance_metadata_and_usage_counts(vis
payload = response.json() payload = response.json()
assert payload['total_tools'] >= 1 assert payload['total_tools'] >= 1
assert payload['used_tools'] >= 1 assert payload['used_tools'] >= 1
search_tool = next(item for item in payload['items'] if item['tool_name'] == 'search_web') search_tool = next(
item for item in payload['items'] if item['tool_name'] in {'search_web', 'web_search'}
)
assert search_tool['permission_class'] == 'external' assert search_tool['permission_class'] == 'external'
assert search_tool['side_effect_scope'] == 'network' assert search_tool['side_effect_scope'] == 'network'
assert search_tool['usage_count'] == 1 assert search_tool['usage_count'] == 1
@@ -516,6 +518,26 @@ async def test_visibility_tools_returns_governance_metadata_and_usage_counts(vis
] ]
@pytest.mark.asyncio
async def test_visibility_debug_returns_observability_and_learning_views(visibility_env):
app, ids = visibility_env
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.get(
'/api/agents/visibility/debug',
params={'conversation_id': ids['conversation_id']},
)
assert response.status_code == 200
payload = response.json()
assert payload['conversation_id'] == ids['conversation_id']
assert 'observability' in payload
assert 'skill_shortlist' in payload
assert 'retrospective_shortlist' in payload
assert 'recent_retrospectives' in payload
assert 'recent_learning_artifacts' in payload
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_visibility_events_reject_invalid_datetime(visibility_env): async def test_visibility_events_reject_invalid_datetime(visibility_env):
app, ids = visibility_env app, ids = visibility_env

View File

@@ -73,3 +73,25 @@ async def test_list_conversations_succeeds_when_agent_state_column_was_missing(c
assert len(payload) == 1 assert len(payload) == 1
assert payload[0]['title'] == 'Existing conversation' assert payload[0]['title'] == 'Existing conversation'
assert payload[0]['message_count'] == 3 assert payload[0]['message_count'] == 3
@pytest.mark.asyncio
async def test_chat_stream_emits_error_event_when_agent_service_fails_before_stream_starts(
conversation_env,
monkeypatch,
):
async def fail_chat(*args, **kwargs):
raise RuntimeError('stream boot failed')
monkeypatch.setattr('app.routers.conversation.AgentService.chat', fail_chat)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat/stream',
json={'message': 'hello'},
)
assert response.status_code == 200
assert 'event: error' in response.text
assert 'stream boot failed' in response.text

View File

@@ -0,0 +1,115 @@
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns
@pytest.mark.anyio
async def test_ensure_memory_columns_adds_importance_tracking_fields_for_existing_user_memories_table(tmp_path):
db_path = tmp_path / 'test_user_memories.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
async with engine.begin() as conn:
await conn.execute(text(
'''
CREATE TABLE user_memories (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
memory_type VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
importance INTEGER,
is_recalled BOOLEAN,
recall_count INTEGER,
source_conversation_id VARCHAR(36),
extracted_at DATETIME,
last_recalled_at DATETIME,
created_at DATETIME,
updated_at DATETIME
)
'''
))
result = await conn.execute(text("PRAGMA table_info(user_memories)"))
columns_before = {row[1] for row in result.fetchall()}
assert 'frequency_count' not in columns_before
assert 'importance_score' not in columns_before
assert 'decay_score' not in columns_before
await ensure_memory_columns(conn)
result = await conn.execute(text("PRAGMA table_info(user_memories)"))
columns_after = {row[1] for row in result.fetchall()}
assert 'frequency_count' in columns_after
assert 'emotion_tags' in columns_after
assert 'importance_score' in columns_after
assert 'importance_level' in columns_after
assert 'associated_topics' in columns_after
assert 'decay_score' in columns_after
assert 'is_archived' in columns_after
assert 'last_accessed_at' in columns_after
assert 'archive_at' in columns_after
await engine.dispose()
@pytest.mark.anyio
async def test_ensure_skill_columns_adds_lifecycle_fields_for_existing_skills_table(tmp_path):
db_path = tmp_path / 'test_skills.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
async with engine.begin() as conn:
await conn.execute(text(
'''
CREATE TABLE skills (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
instructions TEXT NOT NULL,
agent_type VARCHAR(50) NOT NULL,
visibility VARCHAR(20),
is_active BOOLEAN,
owner_id VARCHAR(36),
created_at DATETIME,
updated_at DATETIME
)
'''
))
result = await conn.execute(text("PRAGMA table_info(skills)"))
columns_before = {row[1] for row in result.fetchall()}
assert 'status' not in columns_before
assert 'effectiveness' not in columns_before
await ensure_skill_columns(conn)
result = await conn.execute(text("PRAGMA table_info(skills)"))
columns_after = {row[1] for row in result.fetchall()}
assert 'status' in columns_after
assert 'scope' in columns_after
assert 'effectiveness' in columns_after
assert 'review_after' in columns_after
assert 'activation_count' in columns_after
assert 'last_activated_at' in columns_after
await engine.dispose()
@pytest.mark.anyio
async def test_ensure_learning_artifact_tables_creates_table_and_indexes(tmp_path):
db_path = tmp_path / 'test_learning_artifacts.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
async with engine.begin() as conn:
await ensure_learning_artifact_tables(conn)
result = await conn.execute(text("PRAGMA table_info(learning_artifacts)"))
columns = {row[1] for row in result.fetchall()}
assert 'artifact_type' in columns
assert 'artifact_key' in columns
assert 'summary_text' in columns
assert 'payload' in columns
indexes = await conn.execute(text("PRAGMA index_list(learning_artifacts)"))
index_names = {row[1] for row in indexes.fetchall()}
assert 'ix_learning_artifacts_user_id' in index_names
assert 'ix_learning_artifacts_artifact_type' in index_names
await engine.dispose()

View File

@@ -54,6 +54,9 @@ async def skill_env(tmp_path, monkeypatch):
required_context=[], required_context=[],
visibility='private', visibility='private',
is_active=True, is_active=True,
status='active',
scope=['schedule_planner'],
effectiveness=0.88,
owner_id=user.id, owner_id=user.id,
), ),
Skill( Skill(
@@ -65,6 +68,9 @@ async def skill_env(tmp_path, monkeypatch):
required_context=[], required_context=[],
visibility='private', visibility='private',
is_active=True, is_active=True,
status='shadow',
scope=['executor'],
effectiveness=0.41,
owner_id=user.id, owner_id=user.id,
), ),
Skill( Skill(
@@ -76,6 +82,8 @@ async def skill_env(tmp_path, monkeypatch):
required_context=[], required_context=[],
visibility='private', visibility='private',
is_active=True, is_active=True,
status='active',
scope=['schedule_planner'],
owner_id=other_user.id, owner_id=other_user.id,
), ),
]) ])
@@ -188,3 +196,9 @@ async def test_list_skills_without_agent_type_returns_current_user_skills(skill_
assert all(isinstance(item['updated_at'], str) for item in payload) assert all(isinstance(item['updated_at'], str) for item in payload)
assert all('is_builtin' in item for item in payload) assert all('is_builtin' in item for item in payload)
assert all(item['is_builtin'] is False for item in payload) assert all(item['is_builtin'] is False for item in payload)
assert all('status' in item for item in payload)
assert all('scope' in item for item in payload)
assert any(item['status'] == 'shadow' for item in payload)
executor = next(item for item in payload if item['name'] == 'Executor skill')
assert executor['scope'] == ['executor']
assert executor['effectiveness'] == 0.41

View File

@@ -0,0 +1,130 @@
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_system_config_returns_location_and_weather(monkeypatch):
async def fake_get_config(self):
return {
'location': 'wuhan',
'weather_code': 3,
'weather_summary': 'Overcast 22°C',
}
monkeypatch.setattr('app.routers.system.SystemService.get_config', fake_get_config)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.get('/api/system/config')
assert response.status_code == 200
assert response.json() == {
'location': 'wuhan',
'weather_code': 3,
'weather_summary': 'Overcast 22°C',
}
@pytest.mark.asyncio
async def test_system_config_gracefully_returns_unavailable_weather(monkeypatch):
async def fake_get_config(self):
return {
'location': 'wuhan',
'weather_code': None,
'weather_summary': 'Weather unavailable',
}
monkeypatch.setattr('app.routers.system.SystemService.get_config', fake_get_config)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.get('/api/system/config')
assert response.status_code == 200
assert response.json() == {
'location': 'wuhan',
'weather_code': None,
'weather_summary': 'Weather unavailable',
}
class FakeWeatherResponse:
def __init__(self, payload: dict, status_code: int = 200):
self._payload = payload
self.status_code = status_code
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError(
'request failed',
request=httpx.Request('GET', 'https://wttr.in/wuhan?format=j1'),
response=httpx.Response(self.status_code, request=httpx.Request('GET', 'https://wttr.in/wuhan?format=j1')),
)
def json(self):
return self._payload
class FakeAsyncClient:
def __init__(self, *, response=None, error=None, **kwargs):
self._response = response
self._error = error
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url, *, params=None):
if self._error is not None:
raise self._error
return self._response
@pytest.mark.asyncio
async def test_system_service_get_config_fetches_weather(monkeypatch):
monkeypatch.setattr(
'app.services.system_service.httpx.AsyncClient',
lambda **kwargs: FakeAsyncClient(
response=FakeWeatherResponse({'current_condition': [{'weatherCode': '61', 'temp_C': '18'}]}),
**kwargs,
),
)
from app.services.system_service import SystemService
service = SystemService()
monkeypatch.setattr(service._settings, 'LOCATION', 'wuhan')
payload = await service.get_config()
assert payload == {
'location': 'wuhan',
'weather_code': 61,
'weather_summary': 'Rain 18°C',
}
@pytest.mark.asyncio
async def test_system_service_get_config_handles_weather_failure(monkeypatch):
monkeypatch.setattr(
'app.services.system_service.httpx.AsyncClient',
lambda **kwargs: FakeAsyncClient(error=httpx.TimeoutException('timed out'), **kwargs),
)
from app.services.system_service import SystemService
service = SystemService()
monkeypatch.setattr(service._settings, 'LOCATION', 'wuhan')
payload = await service.get_config()
assert payload == {
'location': 'wuhan',
'weather_code': None,
'weather_summary': 'Weather unavailable',
}

Binary file not shown.

View File

@@ -1 +0,0 @@
VITE_API_URL=http://127.0.0.1:3337

View File

@@ -101,7 +101,8 @@ export const conversationApi = {
handlers: ChatStreamHandlers = {}, handlers: ChatStreamHandlers = {},
) { ) {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/conversations/chat/stream`, { const baseURL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL
const response = await fetch(`${baseURL}/api/conversations/chat/stream`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -13,17 +13,53 @@ export function useClientTime() {
const weatherSummary = ref('Weather unavailable') const weatherSummary = ref('Weather unavailable')
const weatherCode = ref<number | null>(null) const weatherCode = ref<number | null>(null)
let clientTimeTimer: ReturnType<typeof setInterval> | null = null let clientTimeTimer: ReturnType<typeof setInterval> | null = null
const WEATHER_CACHE_KEY = 'jarvis:clientWeather'
// Load location from backend config function loadWeatherCache() {
async function loadLocation() { if (typeof window === 'undefined') return
try {
const raw = window.localStorage.getItem(WEATHER_CACHE_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as { city?: string; weather_summary?: string; weather_code?: number | null; cached_at?: number }
if (typeof parsed.city === 'string' && parsed.city.trim()) city.value = parsed.city
if (typeof parsed.weather_summary === 'string' && parsed.weather_summary.trim()) weatherSummary.value = parsed.weather_summary
if (typeof parsed.weather_code === 'number') weatherCode.value = parsed.weather_code
} catch {
// ignore cache parse errors
}
}
function saveWeatherCache(payload: { city: string; weather_summary: string; weather_code: number | null }) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(WEATHER_CACHE_KEY, JSON.stringify({ ...payload, cached_at: Date.now() }))
} catch {
// ignore storage errors
}
}
// Read cache synchronously during setup to avoid initial render flicker (icon showing as X/na).
loadWeatherCache()
async function loadSystemConfig() {
try { try {
const response = await fetch('/api/system/config') const response = await fetch('/api/system/config')
if (response.ok) { if (!response.ok) throw new Error('system config request failed')
const config = await response.json() const config = await response.json()
city.value = config.location || 'Location' city.value = typeof config.location === 'string' && config.location.trim() ? config.location : 'Location'
} weatherCode.value = typeof config.weather_code === 'number' ? config.weather_code : null
weatherSummary.value = typeof config.weather_summary === 'string' && config.weather_summary.trim()
? config.weather_summary
: 'Weather unavailable'
saveWeatherCache({ city: city.value, weather_summary: weatherSummary.value, weather_code: weatherCode.value })
} catch { } catch {
city.value = import.meta.env.VITE_LOCATION || 'Location' // If we already have cached weather on screen, keep it to avoid UI flicker (icon showing as "X"/na).
const hasExistingWeather = weatherCode.value !== null || (weatherSummary.value && weatherSummary.value !== 'Weather unavailable')
if (!hasExistingWeather) {
city.value = 'Location'
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
}
} }
} }
@@ -40,6 +76,27 @@ export function useClientTime() {
} }
function weatherCodeLabel(code: number | null | undefined) { function weatherCodeLabel(code: number | null | undefined) {
// Backend may return wttr.in condition codes (e.g. 113/116/119...), normalize to user-friendly labels.
if (typeof code === 'number' && code > 99) {
if (code === 113) return 'Clear'
if (code === 116) return 'Partly Cloudy'
if (code === 119 || code === 122) return 'Overcast'
if (code === 143) return 'Fog'
if ([200, 386, 389].includes(code)) return 'Thunderstorm'
if (
[
176, 263, 266, 281, 293, 296, 299, 302, 305, 308,
311, 314, 317, 350, 353, 356, 359, 362,
].includes(code)
) return 'Rain'
if (
[
179, 182, 185, 227, 230, 323, 326, 329, 332, 335,
338, 368, 371, 374, 377, 392, 395,
].includes(code)
) return 'Snow'
return 'Weather'
}
if (code === 0) return 'Clear' if (code === 0) return 'Clear'
if (code === 1 || code === 2) return 'Partly Cloudy' if (code === 1 || code === 2) return 'Partly Cloudy'
if (code === 3) return 'Overcast' if (code === 3) return 'Overcast'
@@ -48,11 +105,34 @@ export function useClientTime() {
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain' if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain'
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow' if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm' if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
return 'Weather' return 'Weather unavailable'
} }
const weatherIcon = computed(() => { const weatherIcon = computed(() => {
const code = weatherCode.value const code = weatherCode.value
if (code === null || code === undefined) return ''
// Support wttr.in weather codes (commonly 113/116/119/122/143/...).
if (typeof code === 'number' && code > 99) {
if (code === 113) return 'wi-day-sunny'
if (code === 116) return 'wi-day-cloudy'
if (code === 119) return 'wi-cloudy'
if (code === 122) return 'wi-cloudy'
if (code === 143) return 'wi-fog'
if ([200, 386, 389].includes(code)) return 'wi-thunderstorm'
if (
[
176, 263, 266, 281, 293, 296, 299, 302, 305, 308,
311, 314, 317, 350, 353, 356, 359, 362,
].includes(code)
) return 'wi-rain'
if (
[
179, 182, 185, 227, 230, 323, 326, 329, 332, 335,
338, 368, 371, 374, 377, 392, 395,
].includes(code)
) return 'wi-snow'
return ''
}
if (code === 0) return 'wi-day-sunny' if (code === 0) return 'wi-day-sunny'
if (code === 1) return 'wi-day-cloudy' if (code === 1) return 'wi-day-cloudy'
if (code === 2) return 'wi-day-cloudy-gusts' if (code === 2) return 'wi-day-cloudy-gusts'
@@ -65,56 +145,13 @@ export function useClientTime() {
if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain' if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain'
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow' if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm' if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
return 'wi-day-sunny' return ''
}) })
async function loadWeather(latitude: number, longitude: number) { onMounted(async () => {
try {
// Fetch weather data
const weatherResp = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,weather_code&timezone=auto`,
)
if (!weatherResp.ok) throw new Error('weather request failed')
const weatherData = await weatherResp.json()
const current = weatherData.current ?? {}
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
// Only fetch city name if not already set by config
if (city.value === 'Location') {
try {
const geoResp = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10`,
)
if (geoResp.ok) {
const geoData = await geoResp.json()
city.value = geoData.address?.city ?? geoData.address?.town ?? geoData.address?.county ?? geoData.display_name?.split(',')[0] ?? 'Location'
}
} catch {
city.value = 'Location'
}
}
} catch {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
}
}
onMounted(() => {
updateClientTime() updateClientTime()
clientTimeTimer = setInterval(updateClientTime, 1000) clientTimeTimer = setInterval(updateClientTime, 1000)
void loadLocation() await loadSystemConfig()
if (!navigator.geolocation) {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
return
}
navigator.geolocation.getCurrentPosition(
(position) => { void loadWeather(position.coords.latitude, position.coords.longitude) },
() => { weatherCode.value = null; weatherSummary.value = 'Weather unavailable' },
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -122,7 +159,15 @@ export function useClientTime() {
}) })
return { return {
clientTime, city, weatherSummary, weatherCode, weatherIcon, clientTime,
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather city,
weatherSummary,
weatherCode,
weatherIcon,
updateClientTime,
formatClientDate,
formatClientClock,
weatherCodeLabel,
loadSystemConfig,
} }
} }

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch, toRef } from 'vue' import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next' import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter' import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
import type { Conversation } from '@/api/conversation' import type { Conversation } from '@/api/conversation'
@@ -18,20 +18,24 @@ export const sidebarCollapsedModules = [
{ id: 'review', label: '复盘', icon: CornerDownLeft }, { id: 'review', label: '复盘', icon: CornerDownLeft },
] ]
function formatDateKey(date: Date) { export function formatDateKey(date: Date) {
const year = date.getUTCFullYear() const year = date.getFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
function formatMonthKey(date: Date) { function formatMonthKey(date: Date) {
const year = date.getUTCFullYear() const year = date.getFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
return `${year}-${month}` return `${year}-${month}`
} }
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void, conversationsRef: Conversation[] = []) { export function useSidebarPlan(
clientTimeRef: { value: Date },
loadDailyDigestFn: () => void,
conversationsRef: Ref<Conversation[]> | Conversation[] = [],
) {
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null) const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([]) const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
const selectedDate = ref<string | null>(null) const selectedDate = ref<string | null>(null)
@@ -41,9 +45,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
const map = new Map<string, boolean>() const map = new Map<string, boolean>()
const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? []) const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? [])
conversations.forEach((conv) => { conversations.forEach((conv) => {
const date = new Date(conv.updated_at) map.set(formatDateKey(new Date(conv.updated_at)), true)
const dateKey = formatDateKey(date)
map.set(dateKey, true)
}) })
return map return map
}) })
@@ -52,22 +54,22 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item]))) const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
const calendarCells = computed(() => { const calendarCells = computed(() => {
const year = clientTimeRef.value.getUTCFullYear() const year = clientTimeRef.value.getFullYear()
const month = clientTimeRef.value.getUTCMonth() const month = clientTimeRef.value.getMonth()
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(Date.UTC(year, month, 1)).getUTCDay() + 6) % 7 const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const today = clientTimeRef.value.getUTCDate() const todayKey = todayDateKey.value
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = [] const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) { for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false }) cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
} }
for (let day = 1; day <= daysInMonth; day += 1) { for (let day = 1; day <= daysInMonth; day += 1) {
const monthDate = new Date(Date.UTC(year, month, day)) const monthDate = new Date(year, month, day)
const dateKey = formatDateKey(monthDate) const dateKey = formatDateKey(monthDate)
const summary = monthPlanSummaryMap.value.get(dateKey) const summary = monthPlanSummaryMap.value.get(dateKey)
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0) const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
const hasConv = conversationDateMap.value.get(dateKey) || false const hasConv = conversationDateMap.value.get(dateKey) || false
cells.push({ key: dateKey, value: day, active: day === today, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv }) cells.push({ key: dateKey, value: day, active: dateKey === todayKey, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
} }
while (cells.length % 7 !== 0) { while (cells.length % 7 !== 0) {
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false }) cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
@@ -75,8 +77,8 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
return cells return cells
}) })
const calendarYear = computed(() => clientTimeRef.value.getUTCFullYear()) const calendarYear = computed(() => clientTimeRef.value.getFullYear())
const calendarMonth = computed(() => clientTimeRef.value.getUTCMonth() + 1) const calendarMonth = computed(() => clientTimeRef.value.getMonth() + 1)
const todayPlanCounters = computed(() => { const todayPlanCounters = computed(() => {
const detail = todayPlanDetail.value const detail = todayPlanDetail.value

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, toRef } from 'vue'
import { import {
ChevronRight, ChevronRight,
Send, Send,
@@ -25,7 +25,7 @@ import { useChatView } from '@/pages/chat/composables/useChatView'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView' import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest' import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest'
import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime' import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime'
import { useSidebarPlan } from '@/pages/chat/composables/useSidebarPlan' import { useSidebarPlan, formatDateKey } from '@/pages/chat/composables/useSidebarPlan'
// --- Chat view (core messaging logic) --- // --- Chat view (core messaging logic) ---
const { const {
@@ -79,7 +79,7 @@ const {
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections, sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules, sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
selectedDate, selectCalendarDate selectedDate, selectCalendarDate
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations) } = useSidebarPlan(clientTime, loadDailyDigest, toRef(store, 'conversations'))
// --- Local UI state --- // --- Local UI state ---
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
@@ -118,27 +118,16 @@ function handleOpenPreview(doc: any) { previewDoc.value = doc }
function handleCalendarDateSelect(dateKey: string) { function handleCalendarDateSelect(dateKey: string) {
selectCalendarDate(dateKey) selectCalendarDate(dateKey)
// Reload conversations to get latest data
loadConversations().then(() => { loadConversations().then(() => {
// Find conversation that matches the selected date (by updated_at) const conversations = store.conversations
// Use UTC to avoid timezone issues const conversation = conversations.find((conv: { id: string; updated_at: string }) => formatDateKey(new Date(conv.updated_at)) === dateKey)
const [year, month, day] = dateKey.split('-').map(Number)
const targetDateStart = new Date(Date.UTC(year, month - 1, day))
const targetDateEnd = new Date(Date.UTC(year, month - 1, day + 1))
// Find conversation that falls on the selected date
const conversations = store.conversations.value ?? store.conversations
const conversation = conversations.find((conv) => {
const convDate = new Date(conv.updated_at)
return convDate >= targetDateStart && convDate < targetDateEnd
})
if (conversation) { if (conversation) {
selectConversation(conversation.id) selectConversation(conversation.id)
} else { return
// No conversation for this date, create a new one
newConversation()
} }
newConversation()
}).catch((err) => { }).catch((err) => {
console.error('[Calendar] Error loading conversations:', err) console.error('[Calendar] Error loading conversations:', err)
}) })

View File

@@ -1,10 +1,44 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path' import path from 'path'
import fs from 'fs'
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '') const env = loadEnv(mode, __dirname, '')
function parseDotenvFile(filePath: string) {
try {
if (!fs.existsSync(filePath)) return {}
const text = fs.readFileSync(filePath, 'utf-8')
const result: Record<string, string> = {}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq <= 0) continue
const key = line.slice(0, eq).trim()
let value = line.slice(eq + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
} catch {
return {}
}
}
// Vite only loads env files under `frontend/` by default.
// Many Jarvis setups keep HOST/PORT in repo root `.env`, so we read it explicitly as a fallback.
const rootEnvPath = path.resolve(__dirname, '../.env')
const rootEnv = parseDotenvFile(rootEnvPath)
const rootHost = (rootEnv.HOST || '127.0.0.1').trim()
const rootPort = (rootEnv.PORT || '').trim()
const rootApi = (rootEnv.VITE_API_URL || (rootPort ? `http://${rootHost}:${rootPort}` : '')).trim()
const apiTarget = env.VITE_API_URL || rootApi || 'http://localhost:8000'
return { return {
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
@@ -15,7 +49,7 @@ export default defineConfig(({ mode }) => {
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: env.VITE_API_URL, target: apiTarget,
changeOrigin: true, changeOrigin: true,
}, },
}, },