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/
.worktrees/
# Demo (excluded from version control)
demo/
# Lock files (use in development, commit in production)
# uv.lock - 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,
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.runtime_metrics import (
coerce_cost_thresholds,
@@ -36,6 +39,14 @@ from app.agents.runtime_metrics import (
)
from app.agents.schemas.event import AgentEvent
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 (
AgentTask,
CollaborationBudget,
@@ -44,6 +55,7 @@ from app.agents.schemas.task import (
TaskResult,
)
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.tools import SUB_COMMANDER_TOOLSETS
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
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:
pending_action = state.get("pending_action") 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)
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(
state: AgentState, system_prompt: str, role: AgentRole, sub_commander: str
) -> list[BaseMessage]:
@@ -1214,6 +1341,19 @@ def _build_system_messages(
if 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)
if continuity_summary:
messages.append(SystemMessage(content=continuity_summary))
@@ -1226,6 +1366,10 @@ def _build_system_messages(
if 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 = {
AgentRole.SCHEDULE_PLANNER: state.get("schedule_context_summary"),
AgentRole.LIBRARIAN: state.get("knowledge_context"),
@@ -1237,7 +1381,11 @@ def _build_system_messages(
role_skill_key = ROLE_SKILL_CONTEXT.get(role)
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:
messages.append(SystemMessage(content=skill_context))
@@ -1322,6 +1470,29 @@ def _build_collaboration_tasks(user_query: str) -> list[AgentTask]:
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:
if state.get("execution_mode") != "collaboration":
return None
@@ -2076,7 +2247,36 @@ async def _run_sub_commander(
)
_record_response_usage(state, response)
response_text = _stringify_message_content(response.content)
response_text_stripped = response_text.strip()
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"):
state["fallback_parse_error"] = None
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)
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(
state: AgentState,
tasks: list[AgentTask],
@@ -2411,6 +2640,14 @@ def _verify_collaboration_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:
summary = f"协作结果不完整,缺少任务结果: {', '.join(missing_task_ids)}"
verdict = verify_task_result(
@@ -2426,20 +2663,51 @@ def _verify_collaboration_results(
verdict = verify_task_result(
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:
summary = f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
summary = (
merge_report.summary
or f"协作模式已完成 {len(normalized_results)}/{len(tasks)} 个子任务,并为每个子任务回收了结果与 evidence。"
)
verdict = verify_task_result(
status="passed", summary=summary, evidence=verification_evidence
)
updated_state = apply_verification_verdict(state, verdict)
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:
_set_phase(state, "phase_2_controlled_collaboration", 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:
state["execution_mode"] = "direct"
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",
payload=budget_snapshot,
)
state["scheduled_subtasks"] = scheduled_subtasks
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
_record_checkpoint(
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")
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(
state,
"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],
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)
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",
)
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(
state,
"collaboration.completed",
@@ -2679,23 +2994,99 @@ async def master_node(state: AgentState) -> AgentState:
return state
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)
clarification_route = _route_from_clarification_context(state, user_query)
if structured_continuity_route is not None:
state["execution_mode"] = "direct"
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:
state["execution_mode"] = "direct"
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(
state.get("messages", [])
):
state["execution_mode"] = "direct"
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:
request_mode, routing_metadata = _select_request_mode(user_query)
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":
collaboration_state = await _run_collaboration_flow(state, user_query)
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"""
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.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 (
BackgroundTaskManager,
BackgroundTask,
@@ -14,7 +23,15 @@ __all__ = [
"TaskStatus",
"RemoteTransport",
"StructuredMessage",
"ParallelExecutionScheduler",
"build_bounded_task_graph",
"build_subtask_budget",
"build_subtask_specs",
"BackgroundTaskManager",
"BackgroundTask",
"ensure_child_links",
"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.learning import (
LearningDecision,
LearningSignal,
PatternCandidate,
SessionRetrospective,
SkillCandidate,
)
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 (
AgentTask,
CollaborationBudget,
@@ -14,12 +33,28 @@ from app.agents.schemas.task import (
__all__ = [
"AgentEvent",
"AgentMessage",
"ExecutionDecision",
"AgentTask",
"CollaborationBudget",
"InterruptRecord",
"LearningDecision",
"LearningSignal",
"MergeReport",
"ParallelWorthiness",
"PatternCandidate",
"RecoveryRecord",
"RuntimeRequestContext",
"SessionRetrospective",
"SkillActivationRecord",
"SkillCandidate",
"SkillShortlistEntry",
"SubTaskResult",
"SubTaskSpec",
"TaskGraph",
"TaskNode",
"TaskLifecycleStatus",
"TaskResult",
"TaskResultStatus",
"VerificationReport",
"VerificationStatus",
]

View File

@@ -7,10 +7,21 @@ from pydantic import BaseModel, Field
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.result",
"agent.verify.started",
"agent.verify.completed",
"agent.retrospective.created",
"agent.learning.decision",
"agent.skill.lifecycle.changed",
"agent.rollback.triggered",
"agent.created",
"agent.spawn.blocked",
"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"""
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",
]
"""Skill package."""

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
enabled: bool = True # 是否启用
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]:
return {
@@ -35,6 +39,10 @@ class SkillMetadata:
"source_id": self.source_id,
"enabled": self.enabled,
"tools": self.tools,
"status": self.status,
"scope": self.scope,
"effectiveness": self.effectiveness,
"review_after": self.review_after,
}
@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
current_datetime_context: 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
routing_decision: dict[str, Any] | None
@@ -254,6 +266,18 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
memory_context=None,
current_datetime_context=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,
routing_decision=None,
continuity_state=None,

View File

@@ -104,6 +104,15 @@ class Settings(BaseSettings):
WEB_SEARCH_DEFAULT_LIMIT: int = 5
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.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_conversation_columns(conn)
await ensure_document_columns(conn)
await ensure_memory_columns(conn)
await ensure_user_columns(conn)
await ensure_forum_columns(conn)
await ensure_agent_columns(conn)
await ensure_skill_columns(conn)
await ensure_learning_artifact_tables(conn)
async def ensure_log_columns(conn):
@@ -115,6 +117,28 @@ async def ensure_document_columns(conn):
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):
rows = await _get_table_info(conn, 'users')
if not rows:
@@ -181,6 +205,14 @@ async def ensure_skill_columns(conn):
'output_format': "ALTER TABLE skills ADD COLUMN output_format TEXT",
'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)",
'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():
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):
result = await conn.execute(text("SELECT id, email, username FROM users ORDER BY created_at, id"))
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.conversation import Conversation, Message
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.brain import (
BrainEvent,
@@ -20,6 +21,7 @@ from app.models.brain import (
from app.models.todo import DailyTodo, TodoSource
from app.models.reminder import Reminder, ReminderStatus
from app.models.goal import Goal, GoalStatus
from app.models.skill import Skill
from app.models.log import Log, LogType, LogLevel
__all__ = [
@@ -38,6 +40,8 @@ __all__ = [
"Message",
"KGNode",
"KGEdge",
"LearningArtifactRecord",
"SessionRetrospectiveRecord",
"MemorySummary",
"UserMemory",
"BrainEvent",
@@ -53,6 +57,7 @@ __all__ = [
"ReminderStatus",
"Goal",
"GoalStatus",
"Skill",
"Log",
"LogType",
"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 app.models.base import BaseModel
@@ -17,6 +17,14 @@ class Skill(BaseModel):
is_builtin = Column(Boolean, default=False, nullable=False)
team_id = Column(String(36), ForeignKey("users.id"), nullable=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 = relationship("User", foreign_keys=[owner_id])

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
from app.models.agent import Agent
@@ -37,6 +38,7 @@ from app.schemas.agent import (
AgentVisibilityVerifierOut,
)
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"])
@@ -662,6 +664,59 @@ async def get_visibility_tools(
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)
async def create_agent(
data: AgentCreate,

View File

@@ -145,6 +145,9 @@ async def chat_stream(
except ValueError as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
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"

View File

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

View File

@@ -12,4 +12,4 @@ async def get_system_status():
@router.get('/config')
async def get_system_config():
"""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"
team_id: Optional[str] = None
is_active: bool = True
status: str = "active"
scope: list[str] = []
effectiveness: Optional[float] = None
review_after: Optional[datetime] = None
class SkillUpdate(BaseModel):
@@ -28,6 +32,10 @@ class SkillUpdate(BaseModel):
visibility: Optional[str] = None
team_id: Optional[str] = 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):
@@ -43,6 +51,12 @@ class SkillOut(BaseModel):
is_builtin: bool
team_id: Optional[str]
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
created_at: datetime
updated_at: datetime

View File

@@ -7,12 +7,13 @@ import json
import uuid
import logging
from datetime import UTC, datetime
from time import perf_counter
from typing import Any, AsyncGenerator
import asyncio
from openai import BadRequestError
from sqlalchemy.ext.asyncio import AsyncSession
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.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.agents.graph import get_agent_graph
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.retriever import shortlist_skills_for_request
from app.services import memory_service
from app.services.brain_service import BrainService
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.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]:
@@ -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:
capabilities = resolve_provider_capabilities(user_llm_config)
error_text = str(error).lower()
@@ -461,18 +512,27 @@ class AgentService:
async def _build_agent_state(
self,
*,
request_id: str,
user_id: str,
conversation: Conversation,
raw_user_query: str,
full_message: str,
memory_context: str | None,
current_datetime_context: str,
current_datetime_reference: dict[str, str],
user_llm_config: dict | None,
runtime_request_context: RuntimeRequestContext,
recalled_retrospectives: list[dict[str, Any]],
skill_shortlist: list[dict[str, Any]],
) -> dict[str, Any]:
state = initial_state(user_id, conversation.id)
runtime_summary = render_runtime_request_context_summary(runtime_request_context)
state.update(
{
"messages": [HumanMessage(content=full_message)],
"messages": [
SystemMessage(content=runtime_summary),
HumanMessage(content=full_message),
],
"memory_context": memory_context,
"current_datetime_context": current_datetime_context,
"current_datetime_reference": current_datetime_reference,
@@ -482,9 +542,119 @@ class AgentService:
previous_snapshot = await self._load_continuity_snapshot(conversation)
if 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
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(
self,
user_id: str,
@@ -610,21 +780,38 @@ class AgentService:
async def run_agent():
collected = ""
state: dict[str, Any] | None = None
runtime_request_context: RuntimeRequestContext | None = None
set_current_user(user_id)
try:
graph = get_agent_graph()
current_datetime_context, current_datetime_reference = (
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,
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,
memory_context=memory_ctx,
current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference,
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))
@@ -749,7 +936,7 @@ class AgentService:
if collected:
assistant_msg.content = collected
continuity_snapshot = _build_continuity_snapshot(state or {})
assistant_msg.attachments = (
attachments = (
[
{
"kind": "agent_continuity_state",
@@ -757,8 +944,26 @@ class AgentService:
}
]
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 = (
{
"kind": "agent_continuity_state",
@@ -771,8 +976,18 @@ class AgentService:
user_id,
**_build_assistant_event_payload(collected),
)
assistant_msg.attachments = attachments or None
await self.db.commit()
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:
logger.exception("save_assistant_message_failed")
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
@@ -863,14 +1078,30 @@ class AgentService:
current_datetime_context, current_datetime_reference = (
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,
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,
memory_context=memory_ctx,
current_datetime_context=current_datetime_context,
current_datetime_reference=current_datetime_reference,
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))
result_state = await graph.ainvoke(state)
@@ -900,7 +1131,7 @@ class AgentService:
continuity_snapshot = (
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
)
assistant_msg.attachments = (
attachments = (
[
{
"kind": "agent_continuity_state",
@@ -908,8 +1139,26 @@ class AgentService:
}
]
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 = (
{
"kind": "agent_continuity_state",
@@ -918,7 +1167,17 @@ class AgentService:
if continuity_snapshot
else None
)
assistant_msg.attachments = attachments or None
await self.db.commit()
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

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 sqlalchemy.ext.asyncio import AsyncSession
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.user import User
@@ -28,6 +32,10 @@ class SkillService:
visibility=data.get("visibility", "private"),
team_id=data.get("team_id"),
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)
await self.db.commit()
@@ -41,6 +49,17 @@ class SkillService:
)
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(
self,
user_id: str,
@@ -56,7 +75,7 @@ class SkillService:
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:
filters.append(Skill.agent_type == agent_type)
@@ -83,7 +102,7 @@ class SkillService:
update_fields = [
"name", "description", "instructions", "agent_type",
"tools", "required_context", "output_format", "visibility",
"team_id", "is_active"
"team_id", "is_active", "status", "scope", "effectiveness", "review_after"
]
for field in update_fields:
@@ -117,6 +136,7 @@ class SkillService:
and_(
Skill.agent_type == agent_type,
Skill.is_active == True,
Skill.status == "active",
or_(
Skill.visibility == "market",
Skill.visibility == "private"
@@ -125,3 +145,234 @@ class SkillService:
)
)
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 socket
import subprocess
import time
import httpx
try:
import psutil
@@ -15,6 +18,10 @@ class SystemService:
_last_net_bytes_sent: int | None = None
_last_net_bytes_recv: int | 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):
# Import settings here to avoid circular imports
@@ -134,8 +141,95 @@ class SystemService:
'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."""
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 {
'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')
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():
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'] == '我查了外部网页,下面是最新结果摘要。'
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):
fake_llm = TripleResponseFallbackLLM([
'{"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()
assert payload['total_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['side_effect_scope'] == 'network'
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
async def test_visibility_events_reject_invalid_datetime(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 payload[0]['title'] == 'Existing conversation'
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=[],
visibility='private',
is_active=True,
status='active',
scope=['schedule_planner'],
effectiveness=0.88,
owner_id=user.id,
),
Skill(
@@ -65,6 +68,9 @@ async def skill_env(tmp_path, monkeypatch):
required_context=[],
visibility='private',
is_active=True,
status='shadow',
scope=['executor'],
effectiveness=0.41,
owner_id=user.id,
),
Skill(
@@ -76,6 +82,8 @@ async def skill_env(tmp_path, monkeypatch):
required_context=[],
visibility='private',
is_active=True,
status='active',
scope=['schedule_planner'],
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('is_builtin' in item 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 = {},
) {
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',
headers: {
'Content-Type': 'application/json',

View File

@@ -13,17 +13,53 @@ export function useClientTime() {
const weatherSummary = ref('Weather unavailable')
const weatherCode = ref<number | null>(null)
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
const WEATHER_CACHE_KEY = 'jarvis:clientWeather'
// Load location from backend config
async function loadLocation() {
function loadWeatherCache() {
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 {
const response = await fetch('/api/system/config')
if (response.ok) {
const config = await response.json()
city.value = config.location || 'Location'
}
if (!response.ok) throw new Error('system config request failed')
const config = await response.json()
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 {
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) {
// 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 === 1 || code === 2) return 'Partly Cloudy'
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 ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
return 'Weather'
return 'Weather unavailable'
}
const weatherIcon = computed(() => {
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 === 1) return 'wi-day-cloudy'
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 ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
return 'wi-day-sunny'
return ''
})
async function loadWeather(latitude: number, longitude: number) {
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(() => {
onMounted(async () => {
updateClientTime()
clientTimeTimer = setInterval(updateClientTime, 1000)
void loadLocation()
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 },
)
await loadSystemConfig()
})
onUnmounted(() => {
@@ -122,7 +159,15 @@ export function useClientTime() {
})
return {
clientTime, city, weatherSummary, weatherCode, weatherIcon,
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
clientTime,
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 { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
import type { Conversation } from '@/api/conversation'
@@ -18,20 +18,24 @@ export const sidebarCollapsedModules = [
{ id: 'review', label: '复盘', icon: CornerDownLeft },
]
function formatDateKey(date: Date) {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
export function formatDateKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatMonthKey(date: Date) {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
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 monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
const selectedDate = ref<string | null>(null)
@@ -41,9 +45,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
const map = new Map<string, boolean>()
const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? [])
conversations.forEach((conv) => {
const date = new Date(conv.updated_at)
const dateKey = formatDateKey(date)
map.set(dateKey, true)
map.set(formatDateKey(new Date(conv.updated_at)), true)
})
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 calendarCells = computed(() => {
const year = clientTimeRef.value.getUTCFullYear()
const month = clientTimeRef.value.getUTCMonth()
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
const firstDayOffset = (new Date(Date.UTC(year, month, 1)).getUTCDay() + 6) % 7
const today = clientTimeRef.value.getUTCDate()
const year = clientTimeRef.value.getFullYear()
const month = clientTimeRef.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const todayKey = todayDateKey.value
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
}
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 summary = monthPlanSummaryMap.value.get(dateKey)
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
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) {
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
})
const calendarYear = computed(() => clientTimeRef.value.getUTCFullYear())
const calendarMonth = computed(() => clientTimeRef.value.getUTCMonth() + 1)
const calendarYear = computed(() => clientTimeRef.value.getFullYear())
const calendarMonth = computed(() => clientTimeRef.value.getMonth() + 1)
const todayPlanCounters = computed(() => {
const detail = todayPlanDetail.value

View File

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

View File

@@ -1,10 +1,44 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import fs from 'fs'
export default defineConfig(({ mode }) => {
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 {
plugins: [vue()],
resolve: {
@@ -15,7 +49,7 @@ export default defineConfig(({ mode }) => {
server: {
proxy: {
'/api': {
target: env.VITE_API_URL,
target: apiTarget,
changeOrigin: true,
},
},