Compare commits

...

31 Commits

Author SHA1 Message Date
145c43f09c fix(backend): update conversation and schedule center schemas
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:51:11 +08:00
847d9f96db test(backend): add Hermes runtime and task router tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:47 +08:00
7f5b133fad feat(backend): add office router and agent runtime services
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:50:32 +08:00
21c869db62 feat(docs): add development documentation, prototypes, and war-room components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:41 +08:00
1ca8855751 chore(frontend): update styles, vite config, and package dependencies
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:49:08 +08:00
d8f8b0c177 feat(frontend): update schedule center and war room pages
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:53 +08:00
7e6eb6a7b3 feat(frontend): update chat page composables and sidebar plan implementation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:37 +08:00
c70e7e7253 feat(frontend): update API clients and Kanban components with enhanced UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:22 +08:00
39a9058de1 test(backend): update backend router tests for conversation, schedule center, and schema
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:48:07 +08:00
ac49c13965 feat(backend): update database schema and agent service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:53 +08:00
3e39b40a50 feat(backend): enhance task and schedule center APIs with expanded endpoints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 08:47:39 +08:00
8c7cf0732b Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
2026-04-09 17:26:37 +08:00
aa12c92a5a feat(temple): add Temple modal with Tools browser and Skills management 2026-04-08 16:46:02 +08:00
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
536c541a5b feat(frontend): redesign KanbanDetail modal - remove sidebar, add editable title, subtasks with drag-drop 2026-04-07 13:16:34 +08:00
7aef898bf5 fix(frontend): calendar session navigation - enable today click always, show indicator only for dates with sessions, use UTC for consistent date matching 2026-04-07 11:18:07 +08:00
721ddbeef9 feat(frontend): add calendar click to switch conversation by date
- Add selected date state and conversation mapping in useSidebarPlan
- Connect calendar cells to conversation switching logic
- Add conversation indicator dot on dates with sessions
- Only clickable dates show hand cursor (today + dates with conversations)
- Add .selected styling for non-today dates, today keeps blue
- Fix hover effect to only apply to non-today dates
- Add daily doc for session date mapping feature

BREAKING: Calendar click now switches sessions by date
2026-04-07 10:28:31 +08:00
3bff9b3b93 feat(frontend): add four-quadrant kanban task management system
- Add KanbanPanel component with four-quadrant task layout
- Add KanbanDetail component for task configuration modal
- Add "待办" (Todo) module to sidebar collapsed icon rail
- Click TODAY'S STATUS card or sidebar icon to open kanban drawer
- Click quadrant check icon to open detail modal with Teleport to body
- Apply blur effect to sidebar and chat area when detail modal is open
- Import ListTodo icon from lucide-vue-next
- Update sidebar labels to English for consistency
2026-04-06 23:48:52 +08:00
3cf8762b96 fix(frontend): change time format to 12-hour with AM/PM
- Change time locale from zh-CN to en-US to properly display AM/PM
- Increase letter-spacing for better readability (0.08em → 0.12em)
- Update all time displays to use 12-hour format consistently
2026-04-06 22:21:54 +08:00
712d9e1652 feat(frontend): add weather icons and redesign calendar header
Backend changes:
- Add LOCATION configuration option to Settings
- Add /api/system/config endpoint to expose public config
- Implement location priority: config > geolocation > default

Frontend changes:
- Install and integrate weather-icons npm package (Erik Flowers)
- Redesign calendar header with date/time on left, weather/location on right
- Display weather icon using CSS classes instead of SVG components
- Fetch location from backend API on component mount
- Use configured location name (from .env) instead of geocoded result

Layout:
- Left: month/year + current time
- Right: city name + weather description + weather icon
2026-04-06 22:18:44 +08:00
ff042cd932 fix(frontend): remove duplicate calendar title-row from sidebar calendar
- Remove calendar-title-row (year/month + time) that was showing below the main date row
- Keep only the primary date display (jarvis-date-row) at the top
- Also removes unused calendarYear/calendarMonth computed properties
2026-04-06 21:33:45 +08:00
1345 changed files with 29094 additions and 618 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

@@ -61,6 +61,9 @@ class Settings(BaseSettings):
DAILY_PLAN_TIME: str = "00:00"
FORUM_SCAN_INTERVAL_MINUTES: int = 30
# === 位置配置 ===
LOCATION: str = "Location"
# === CORS ===
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
@@ -101,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

@@ -35,14 +35,206 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_task_columns(conn)
await ensure_log_columns(conn)
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_task_columns(conn):
rows = await _get_table_info(conn, 'tasks')
if not rows:
return
columns = {row[1] for row in rows}
required_columns = {
'source': "ALTER TABLE tasks ADD COLUMN source VARCHAR(32) DEFAULT 'manual' NOT NULL",
'conversation_id': "ALTER TABLE tasks ADD COLUMN conversation_id VARCHAR(36)",
'quadrant': "ALTER TABLE tasks ADD COLUMN quadrant VARCHAR(64)",
'assignee_type': "ALTER TABLE tasks ADD COLUMN assignee_type VARCHAR(32)",
'assignee_id': "ALTER TABLE tasks ADD COLUMN assignee_id VARCHAR(255)",
'dispatch_status': "ALTER TABLE tasks ADD COLUMN dispatch_status VARCHAR(32) DEFAULT 'idle' NOT NULL",
'dispatch_run_id': "ALTER TABLE tasks ADD COLUMN dispatch_run_id VARCHAR(64)",
'result_summary': "ALTER TABLE tasks ADD COLUMN result_summary TEXT",
'started_at': "ALTER TABLE tasks ADD COLUMN started_at DATETIME",
'last_synced_at': "ALTER TABLE tasks ADD COLUMN last_synced_at DATETIME",
}
for column, ddl in required_columns.items():
if column not in columns:
await conn.execute(text(ddl))
indexes = {
'ix_tasks_due_date': "CREATE INDEX IF NOT EXISTS ix_tasks_due_date ON tasks (due_date)",
'ix_tasks_source': "CREATE INDEX IF NOT EXISTS ix_tasks_source ON tasks (source)",
'ix_tasks_conversation_id': "CREATE INDEX IF NOT EXISTS ix_tasks_conversation_id ON tasks (conversation_id)",
'ix_tasks_quadrant': "CREATE INDEX IF NOT EXISTS ix_tasks_quadrant ON tasks (quadrant)",
'ix_tasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_type ON tasks (assignee_type)",
'ix_tasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_tasks_assignee_id ON tasks (assignee_id)",
'ix_tasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_status ON tasks (dispatch_status)",
'ix_tasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_tasks_dispatch_run_id ON tasks (dispatch_run_id)",
}
for ddl in indexes.values():
await conn.execute(text(ddl))
history_rows = await _get_table_info(conn, 'task_histories')
if history_rows:
history_columns = {row[1] for row in history_rows}
if 'subtask_id' not in history_columns:
await conn.execute(text("ALTER TABLE task_histories ADD COLUMN subtask_id VARCHAR(36)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_histories_subtask_id ON task_histories (subtask_id)"))
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS task_subtasks (
id VARCHAR(36) PRIMARY KEY,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
task_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'todo',
order_index INTEGER NOT NULL DEFAULT 0,
assignee_type VARCHAR(32),
assignee_id VARCHAR(255),
dispatch_status VARCHAR(32) NOT NULL DEFAULT 'idle',
dispatch_run_id VARCHAR(64),
completed_at DATETIME,
FOREIGN KEY(task_id) REFERENCES tasks (id)
)
"""
)
)
subtask_rows = await _get_table_info(conn, 'task_subtasks')
subtask_columns = {row[1] for row in subtask_rows}
if 'result_summary' not in subtask_columns:
await conn.execute(text("ALTER TABLE task_subtasks ADD COLUMN result_summary TEXT"))
subtask_indexes = {
'ix_task_subtasks_task_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_task_id ON task_subtasks (task_id)",
'ix_task_subtasks_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_status ON task_subtasks (status)",
'ix_task_subtasks_order_index': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_order_index ON task_subtasks (order_index)",
'ix_task_subtasks_assignee_type': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_type ON task_subtasks (assignee_type)",
'ix_task_subtasks_assignee_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_assignee_id ON task_subtasks (assignee_id)",
'ix_task_subtasks_dispatch_status': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_status ON task_subtasks (dispatch_status)",
'ix_task_subtasks_dispatch_run_id': "CREATE INDEX IF NOT EXISTS ix_task_subtasks_dispatch_run_id ON task_subtasks (dispatch_run_id)",
}
for ddl in subtask_indexes.values():
await conn.execute(text(ddl))
# Normalize legacy/invalid enum-like values to prevent ORM Enum decoding failures.
await conn.execute(
text(
"""
UPDATE tasks
SET source = 'manual'
WHERE source IS NULL
OR TRIM(source) = ''
OR source NOT IN ('manual','chat','schedule_center','today_status','commander')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET status = 'todo'
WHERE status IS NULL
OR TRIM(status) = ''
OR status NOT IN ('todo','in_progress','done','cancelled')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET priority = 'medium'
WHERE priority IS NULL
OR TRIM(priority) = ''
OR priority NOT IN ('low','medium','high','urgent')
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET quadrant = NULL
WHERE quadrant IS NOT NULL
AND (TRIM(quadrant) = '' OR quadrant NOT IN (
'urgent-important',
'not-urgent-important',
'urgent-not-important',
'not-urgent-not-important'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET assignee_type = NULL
WHERE assignee_type IS NOT NULL
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE tasks
SET dispatch_status = 'idle'
WHERE dispatch_status IS NULL
OR TRIM(dispatch_status) = ''
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET status = 'todo'
WHERE status IS NULL
OR TRIM(status) = ''
OR status NOT IN ('todo','in_progress','done','cancelled')
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET assignee_type = NULL
WHERE assignee_type IS NOT NULL
AND (TRIM(assignee_type) = '' OR assignee_type NOT IN (
'user','commander','agent','planner','executor','knowledge','analyst','coder','researcher'
))
"""
)
)
await conn.execute(
text(
"""
UPDATE task_subtasks
SET dispatch_status = 'idle'
WHERE dispatch_status IS NULL
OR TRIM(dispatch_status) = ''
OR dispatch_status NOT IN ('idle','queued','running','completed','failed')
"""
)
)
async def ensure_log_columns(conn):
@@ -115,6 +307,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 +395,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 +427,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

@@ -29,6 +29,9 @@ from app.routers import (
agent_skills_router,
agent_sessions_router,
terminal_router,
tools_router,
remote_mount_router,
office_router,
)
from app.routers.scheduler import router as scheduler_router
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
@@ -129,6 +132,9 @@ app.include_router(marketplace_router)
app.include_router(agent_skills_router)
app.include_router(agent_sessions_router)
app.include_router(terminal_router)
app.include_router(tools_router)
app.include_router(remote_mount_router)
app.include_router(office_router)
@app.get("/api/health")

View File

@@ -2,11 +2,22 @@ from app.models.base import Base
from app.models.user import User
from app.models.folder import Folder
from app.models.document import Document, DocumentChunk
from app.models.task import Task, TaskHistory
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
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,7 +31,9 @@ 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
from app.models.remote_mount import RemoteMount, RemoteSyncItem
__all__ = [
"Base",
@@ -29,7 +42,14 @@ __all__ = [
"Document",
"DocumentChunk",
"Task",
"TaskSubTask",
"TaskHistory",
"TaskStatus",
"TaskPriority",
"TaskSource",
"TaskQuadrant",
"TaskAssigneeType",
"TaskDispatchStatus",
"ForumPost",
"ForumReply",
"Agent",
@@ -38,6 +58,8 @@ __all__ = [
"Message",
"KGNode",
"KGEdge",
"LearningArtifactRecord",
"SessionRetrospectiveRecord",
"MemorySummary",
"UserMemory",
"BrainEvent",
@@ -53,7 +75,10 @@ __all__ = [
"ReminderStatus",
"Goal",
"GoalStatus",
"Skill",
"Log",
"LogType",
"LogLevel",
"RemoteMount",
"RemoteSyncItem",
]

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

@@ -0,0 +1,34 @@
from sqlalchemy import Boolean, Column, ForeignKey, String, Text, UniqueConstraint
from app.models.base import BaseModel
class RemoteMount(BaseModel):
__tablename__ = "remote_mounts"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_remote_mount_user_name"),
)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(255), nullable=False)
mount_type = Column(String(32), nullable=False, default="webdav")
base_url = Column(String(1000), nullable=False)
username = Column(String(255), nullable=True)
password_encrypted = Column(Text, nullable=True)
root_path = Column(String(1000), nullable=False, default="/")
is_active = Column(Boolean, nullable=False, default=True)
last_sync_at = Column(String(64), nullable=True)
class RemoteSyncItem(BaseModel):
__tablename__ = "remote_sync_items"
mount_id = Column(String(36), ForeignKey("remote_mounts.id"), nullable=False, index=True)
remote_path = Column(String(2000), nullable=False)
remote_etag = Column(String(512), nullable=True)
remote_modified_at = Column(String(128), nullable=True)
local_folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
local_document_id = Column(String(36), ForeignKey("documents.id"), nullable=True)
sync_status = Column(String(32), nullable=False, default="synced")
last_error = Column(Text, nullable=True)
last_synced_at = Column(String(64), nullable=True)

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

@@ -1,7 +1,8 @@
from sqlalchemy import Column, String, Text, Integer, ForeignKey, DateTime, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
@@ -19,26 +20,144 @@ class TaskPriority(str, PyEnum):
URGENT = "urgent"
class TaskSource(str, PyEnum):
MANUAL = "manual"
CHAT = "chat"
SCHEDULE_CENTER = "schedule_center"
TODAY_STATUS = "today_status"
COMMANDER = "commander"
class TaskQuadrant(str, PyEnum):
URGENT_IMPORTANT = "urgent-important"
NOT_URGENT_IMPORTANT = "not-urgent-important"
URGENT_NOT_IMPORTANT = "urgent-not-important"
NOT_URGENT_NOT_IMPORTANT = "not-urgent-not-important"
class TaskAssigneeType(str, PyEnum):
USER = "user"
COMMANDER = "commander"
AGENT = "agent"
PLANNER = "planner"
EXECUTOR = "executor"
KNOWLEDGE = "knowledge"
ANALYST = "analyst"
CODER = "coder"
RESEARCHER = "researcher"
class TaskDispatchStatus(str, PyEnum):
IDLE = "idle"
QUEUED = "queued"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
DispatchStatus = TaskDispatchStatus
DispatchStatus = TaskDispatchStatus
class TaskHistoryAction(str, PyEnum):
CREATED = "created"
CREATED_FROM_CHAT = "created_from_chat"
UPDATED = "updated"
STATUS_CHANGED = "status_changed"
ASSIGNED = "assigned"
DELETED = "deleted"
SUBTASK_CREATED = "subtask_created"
SUBTASK_UPDATED = "subtask_updated"
SUBTASK_DELETED = "subtask_deleted"
SUBTASK_REORDERED = "subtask_reordered"
DISPATCHED_TO_COMMANDER = "dispatched_to_commander"
DISPATCH_STATUS_CHANGED = "dispatch_status_changed"
def enum_values(enum_cls: type[PyEnum]) -> list[str]:
return [item.value for item in enum_cls]
TASK_STATUS_ENUM = Enum(TaskStatus, values_callable=enum_values)
TASK_PRIORITY_ENUM = Enum(TaskPriority, values_callable=enum_values)
TASK_SOURCE_ENUM = Enum(TaskSource, values_callable=enum_values)
TASK_QUADRANT_ENUM = Enum(TaskQuadrant, values_callable=enum_values)
TASK_ASSIGNEE_TYPE_ENUM = Enum(TaskAssigneeType, values_callable=enum_values)
TASK_DISPATCH_STATUS_ENUM = Enum(TaskDispatchStatus, values_callable=enum_values)
class Task(BaseModel):
__tablename__ = "tasks"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.TODO, nullable=False, index=True)
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM, nullable=False)
due_date = Column(DateTime, nullable=True)
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
priority = Column(TASK_PRIORITY_ENUM, default=TaskPriority.MEDIUM, nullable=False)
due_date = Column(DateTime, nullable=True, index=True)
completed_at = Column(DateTime, nullable=True)
tags = Column(String(1000), nullable=True) # JSON 数组
tags = Column(String(1000), nullable=True) # JSON array
source = Column(TASK_SOURCE_ENUM, default=TaskSource.MANUAL, nullable=False, index=True)
conversation_id = Column(String(36), nullable=True, index=True)
quadrant = Column(TASK_QUADRANT_ENUM, nullable=True, index=True)
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
assignee_id = Column(String(255), nullable=True, index=True)
dispatch_status = Column(
TASK_DISPATCH_STATUS_ENUM,
default=TaskDispatchStatus.IDLE,
nullable=False,
index=True,
)
dispatch_run_id = Column(String(64), nullable=True, index=True)
result_summary = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=True)
last_synced_at = Column(DateTime, nullable=True)
history = relationship("TaskHistory", back_populates="task", cascade="all, delete-orphan")
subtasks = relationship(
"TaskSubTask",
back_populates="task",
cascade="all, delete-orphan",
order_by="TaskSubTask.order_index.asc()",
)
history = relationship(
"TaskHistory",
back_populates="task",
cascade="all, delete-orphan",
order_by="TaskHistory.created_at.desc()",
)
class TaskSubTask(BaseModel):
__tablename__ = "task_subtasks"
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(TASK_STATUS_ENUM, default=TaskStatus.TODO, nullable=False, index=True)
order_index = Column(Integer, default=0, nullable=False, index=True)
assignee_type = Column(TASK_ASSIGNEE_TYPE_ENUM, nullable=True, index=True)
assignee_id = Column(String(255), nullable=True, index=True)
dispatch_status = Column(
TASK_DISPATCH_STATUS_ENUM,
default=TaskDispatchStatus.IDLE,
nullable=False,
index=True,
)
dispatch_run_id = Column(String(64), nullable=True, index=True)
result_summary = Column(Text, nullable=True)
completed_at = Column(DateTime, nullable=True)
task = relationship("Task", back_populates="subtasks")
class TaskHistory(BaseModel):
__tablename__ = "task_histories"
task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True)
action = Column(String(100), nullable=False) # created, status_changed, updated, deleted
subtask_id = Column(String(36), ForeignKey("task_subtasks.id"), nullable=True, index=True)
action = Column(String(100), nullable=False)
old_value = Column(Text, nullable=True)
new_value = Column(Text, nullable=True)

View File

@@ -21,3 +21,6 @@ from app.routers.plugins import _marketplace_router as marketplace_router
from app.routers.agent_skills import router as agent_skills_router
from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router
from app.routers.tools import router as tools_router
from app.routers.remote_mount import router as remote_mount_router
from app.routers.office import router as office_router

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

@@ -100,6 +100,7 @@ async def chat(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -115,7 +116,7 @@ async def chat(
conversation_id=conv_id,
message_id=msg_id,
content=content,
agent_name="jarvis",
agent_name=data.runtime or "jarvis",
model_name=model_name,
)
@@ -141,10 +142,14 @@ async def chat_stream(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
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

@@ -1,17 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List
import shutil
from app.database import get_db
from app.models.folder import Folder
from app.models.user import User
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
from app.routers.auth import get_current_user
from app.schemas.folder import FolderCreate, FolderOut, FolderTreeOut, FolderUpdate
from app.services.document_service import DocumentService
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
"""递归构建文件夹树"""
tree = []
for folder in folders:
if folder.parent_id == parent_id:
@@ -20,30 +23,29 @@ def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[Fold
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
children=children
children=children,
))
return tree
@router.get("", response_model=List[FolderTreeOut])
async def get_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""获取用户的完整文件夹树"""
result = await db.execute(
select(Folder).where(Folder.user_id == current_user.id)
)
folders = result.scalars().all()
return build_folder_tree(list(folders))
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
async def create_folder(
folder_data: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""创建文件夹"""
# 验证父文件夹存在且属于当前用户
if folder_data.parent_id:
result = await db.execute(
select(Folder).where(
@@ -53,13 +55,12 @@ async def create_folder(
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="父文件夹不存在")
# 检查同名文件夹
result = await db.execute(
select(Folder).where(
and_(
Folder.user_id == current_user.id,
Folder.parent_id == folder_data.parent_id,
Folder.name == folder_data.name
Folder.name == folder_data.name,
)
)
)
@@ -69,21 +70,24 @@ async def create_folder(
folder = Folder(
user_id=current_user.id,
name=folder_data.name,
parent_id=folder_data.parent_id
parent_id=folder_data.parent_id,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
document_service = DocumentService(db, current_user.id)
await document_service.ensure_folder_directory(current_user.id, folder.id)
return folder
@router.put("/{folder_id}", response_model=FolderOut)
async def rename_folder(
folder_id: str,
folder_data: FolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""重命名文件夹"""
result = await db.execute(
select(Folder).where(
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
@@ -93,18 +97,22 @@ async def rename_folder(
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
old_name = folder.name
folder.name = folder_data.name
document_service = DocumentService(db, current_user.id)
await document_service.rename_folder_directory(current_user.id, folder.id, old_name, folder_data.name)
await db.commit()
await db.refresh(folder)
return folder
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""删除文件夹(级联删除文档)"""
from app.models.document import Document
from app.services.knowledge_service import KnowledgeService
@@ -117,15 +125,16 @@ async def delete_folder(
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
document_service = DocumentService(db, current_user.id)
folder_path = await document_service._get_storage_directory(current_user.id, folder_id)
async def delete_recursive(fid: str):
# 删除子文件夹(先递归)
children = await db.execute(
select(Folder).where(Folder.parent_id == fid)
)
for child in children.scalars():
await delete_recursive(child.id)
# 删除文档
docs = await db.execute(
select(Document).where(Document.folder_id == fid)
)
@@ -134,10 +143,12 @@ async def delete_folder(
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
await db.delete(doc)
# 删除文件夹本身
folder_to_delete = await db.get(Folder, fid)
if folder_to_delete:
await db.delete(folder_to_delete)
await delete_recursive(folder_id)
await db.commit()
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)

View File

@@ -0,0 +1,179 @@
"""Office Status API - Star Office style visualization for Jarvis agents."""
from datetime import datetime, timedelta
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/office", tags=["office"])
# ============================================================================
# State Definitions (mapped to spaceship areas)
# ============================================================================
# idle → Rest Bay (breakroom)
# writing/researching/executing → Command Console (writing)
# syncing → Server Room (syncing)
# error → Repair Bay (error)
SHIP_AREAS = {
"breakroom": {"x": 200, "y": 300}, # Rest Bay - bottom left
"writing": {"x": 640, "y": 200}, # Command Console - center top
"server": {"x": 640, "y": 400}, # Server Room - center bottom
"error": {"x": 1000, "y": 300}, # Repair Bay - right side
}
STATES = {
"idle": {"name": "待命", "area": "breakroom"},
"writing": {"name": "执行中", "area": "writing"},
"researching": {"name": "研究中", "area": "writing"},
"executing": {"name": "执行中", "area": "writing"},
"syncing": {"name": "同步中", "area": "server"},
"error": {"name": "故障中", "area": "error"},
}
# ============================================================================
# Data Models
# ============================================================================
class AgentState(BaseModel):
agent_id: str
name: str
state: Literal["idle", "writing", "researching", "executing", "syncing", "error"]
detail: str | None = None
area: str | None = None
is_main: bool = False
auth_status: str = "approved" # approved, pending, rejected, offline
class SetStateRequest(BaseModel):
state: str
detail: str | None = None
class OfficeStatus(BaseModel):
state: str
detail: str | None = None
agent_name: str
timestamp: str
class OfficeMemo(BaseModel):
success: bool
date: str
memo: str
# ============================================================================
# In-Memory State (in production, this would come from Jarvis's agent state)
# ============================================================================
_current_state: dict = {
"agent_id": "jarvis-main",
"name": "JARVIS",
"state": "idle",
"detail": "战舰启动中...",
"area": "breakroom",
"is_main": True,
"auth_status": "approved",
}
def normalize_state(state: str | None) -> str:
"""Normalize various state names to our canonical states."""
if not state:
return "idle"
state = state.lower().strip()
if state in ("working", "run", "running"):
return "writing"
if state in ("sync", "syncing"):
return "syncing"
if state in ("research", "researching"):
return "researching"
if state in ("execute", "executing"):
return "executing"
if state == "error":
return "error"
return "idle"
def get_state_info(state: str) -> dict:
"""Get state info including area mapping."""
return STATES.get(state, STATES["idle"])
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/status", response_model=OfficeStatus)
async def get_status():
"""Get current agent status."""
state_info = get_state_info(_current_state["state"])
return OfficeStatus(
state=_current_state["state"],
detail=_current_state.get("detail"),
agent_name=_current_state["name"],
timestamp=datetime.now().isoformat(),
)
@router.get("/yesterday-memo", response_model=OfficeMemo)
async def get_yesterday_memo():
"""Return a lightweight public memo for the Star Office viewer."""
target_date = (datetime.now() - timedelta(days=1)).date().isoformat()
detail = (_current_state.get("detail") or "No detailed log was recorded.").strip()
memo = (
"Yesterday summary\n"
f"- Last known state: {_current_state['state']}\n"
f"- Detail: {detail}\n"
"- Next step: open the command surface and continue from the current work thread."
)
return OfficeMemo(success=True, date=target_date, memo=memo)
@router.post("/set_state")
async def set_state(req: SetStateRequest):
"""Set the current agent state."""
normalized = normalize_state(req.state)
state_info = get_state_info(normalized)
_current_state["state"] = normalized
_current_state["detail"] = req.detail or ""
_current_state["area"] = state_info["area"]
return {
"success": True,
"state": normalized,
"area": state_info["area"],
"detail": _current_state["detail"],
}
@router.get("/agents")
async def get_agents():
"""Get all agents in the office (for multi-agent support)."""
# For now, return just the main agent
# In full implementation, this would query Jarvis's agent registry
state_info = get_state_info(_current_state["state"])
return [
{
"agentId": _current_state["agent_id"],
"name": _current_state["name"],
"state": _current_state["state"],
"detail": _current_state.get("detail", ""),
"area": state_info["area"],
"isMain": _current_state.get("is_main", True),
"authStatus": _current_state.get("auth_status", "approved"),
"updated_at": datetime.now().isoformat(),
}
]
@router.get("/areas")
async def get_areas():
"""Get all spaceship areas with coordinates."""
return SHIP_AREAS
@router.get("/health")
async def health():
"""Health check."""
return {"status": "ok", "service": "office"}

View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.remote_mount import RemoteMount
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.remote_mount import (
RemoteMountCreate,
RemoteMountOut,
RemoteMountTreeOut,
RemoteNodeOut,
RemoteSyncRequest,
RemoteSyncResultOut,
)
from app.services.remote_sync_service import RemoteSyncService
from app.services.secret_service import encrypt_secret
from app.services.webdav_service import WebDavNode, WebDavService
router = APIRouter(prefix="/api/remote-mounts", tags=["远程挂载"])
def _to_node_out(node: WebDavNode) -> RemoteNodeOut:
return RemoteNodeOut(
path=node.path,
name=node.name,
is_dir=node.is_dir,
size=node.size,
modified_at=node.modified_at,
etag=node.etag,
children=[_to_node_out(child) for child in node.children],
)
@router.get("", response_model=list[RemoteMountOut])
async def list_remote_mounts(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RemoteMount).where(RemoteMount.user_id == current_user.id).order_by(RemoteMount.created_at.desc())
)
return list(result.scalars().all())
@router.post("", response_model=RemoteMountOut, status_code=status.HTTP_201_CREATED)
async def create_remote_mount(
payload: RemoteMountCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
existing = await db.execute(
select(RemoteMount).where(and_(RemoteMount.user_id == current_user.id, RemoteMount.name == payload.name))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="同名远程挂载已存在")
mount = RemoteMount(
user_id=current_user.id,
name=payload.name,
mount_type="webdav",
base_url=str(payload.base_url),
username=payload.username,
password_encrypted=encrypt_secret(payload.password),
root_path=payload.root_path,
is_active=True,
)
try:
await WebDavService(mount).list_directory(payload.root_path)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"WebDAV 连接失败: {exc}") from exc
db.add(mount)
await db.commit()
await db.refresh(mount)
return mount
async def _get_user_mount(db: AsyncSession, user_id: str, mount_id: str) -> RemoteMount:
result = await db.execute(
select(RemoteMount).where(and_(RemoteMount.id == mount_id, RemoteMount.user_id == user_id))
)
mount = result.scalar_one_or_none()
if mount is None:
raise HTTPException(status_code=404, detail="远程挂载不存在")
return mount
@router.get("/{mount_id}/tree", response_model=RemoteMountTreeOut)
async def get_remote_tree(
mount_id: str,
path: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mount = await _get_user_mount(db, current_user.id, mount_id)
try:
nodes = await WebDavService(mount).list_tree(path or mount.root_path)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"远程目录读取失败: {exc}") from exc
return RemoteMountTreeOut(
mount_id=mount.id,
root_path=path or mount.root_path,
nodes=[_to_node_out(node) for node in nodes],
)
@router.post("/{mount_id}/sync", response_model=RemoteSyncResultOut)
async def sync_remote_mount(
mount_id: str,
payload: RemoteSyncRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mount = await _get_user_mount(db, current_user.id, mount_id)
try:
result = await RemoteSyncService(db, current_user.id).sync_remote_path(
mount,
payload.remote_path,
payload.local_folder_id,
payload.mode,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"远程同步失败: {exc}") from exc
return RemoteSyncResultOut(**result)

View File

@@ -1,25 +1,62 @@
from calendar import monthrange
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority
from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
from app.models.todo import DailyTodo
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.schedule_center import (
ScheduleCenterCommanderSummaryOut,
ScheduleCenterDateOut,
ScheduleCenterDaySummary,
ScheduleCenterFocusTaskOut,
ScheduleCenterMonthOut,
ScheduleCenterQuadrantOut,
ScheduleCenterQuadrantTaskOut,
)
from app.schemas.task import build_task_out
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = {
TaskQuadrant.URGENT_IMPORTANT: {
"title": "重要且紧急",
"subtitle": "CRITICAL",
"color": "#ff4757",
"glow_color": "rgba(255, 71, 87, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_IMPORTANT: {
"title": "重要不紧急",
"subtitle": "PLANNED",
"color": "#ffd93d",
"glow_color": "rgba(255, 217, 61, 0.4)",
"icon": "",
},
TaskQuadrant.URGENT_NOT_IMPORTANT: {
"title": "紧急不重要",
"subtitle": "DELEGATE",
"color": "#00d4ff",
"glow_color": "rgba(0, 212, 255, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: {
"title": "不重要不紧急",
"subtitle": "ELIMINATE",
"color": "#6bcf7f",
"glow_color": "rgba(107, 207, 127, 0.4)",
"icon": "",
},
}
def _build_summary(
target_date: str,
@@ -39,6 +76,146 @@ def _build_summary(
)
def _coerce_enum(value, enum_cls, default=None):
if value is None:
return default
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return default
for item in enum_cls:
if raw == item.value or raw.lower() == item.value:
return item
if raw.upper() == item.name:
return item
return default
def _derive_quadrant(task: Task) -> TaskQuadrant:
quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None)
if quadrant is not None:
return quadrant
priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM)
status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO)
if priority in {TaskPriority.HIGH, TaskPriority.URGENT}:
return TaskQuadrant.URGENT_IMPORTANT
if status == TaskStatus.IN_PROGRESS:
return TaskQuadrant.NOT_URGENT_IMPORTANT
if priority == TaskPriority.MEDIUM:
return TaskQuadrant.URGENT_NOT_IMPORTANT
return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT
def _enum_value(value) -> str | None:
if value is None:
return None
if hasattr(value, "value"):
return str(value.value)
if isinstance(value, str):
raw = value.strip()
return raw or None
return str(value)
def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]:
priority_rank = {
TaskPriority.URGENT: 0,
TaskPriority.HIGH: 1,
TaskPriority.MEDIUM: 2,
TaskPriority.LOW: 3,
}
status_rank = {
TaskStatus.IN_PROGRESS: 0,
TaskStatus.TODO: 1,
TaskStatus.DONE: 2,
TaskStatus.CANCELLED: 3,
}
ordered = sorted(
tasks,
key=lambda item: (
status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99),
priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99),
item.created_at,
),
)
return [
ScheduleCenterFocusTaskOut(
id=item.id,
title=item.title,
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM),
quadrant=_derive_quadrant(item),
assignee_type=_enum_value(item.assignee_type),
assignee_id=item.assignee_id,
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
due_date=item.due_date,
)
for item in ordered[:6]
]
def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]:
buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = {
quadrant: [] for quadrant in QUADRANT_META
}
for task in tasks:
quadrant = _derive_quadrant(task)
buckets[quadrant].append(
ScheduleCenterQuadrantTaskOut(
id=task.id,
title=task.title,
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
assignee_type=_enum_value(task.assignee_type),
assignee_id=task.assignee_id,
)
)
return [
ScheduleCenterQuadrantOut(
id=quadrant,
title=meta["title"],
subtitle=meta["subtitle"],
color=meta["color"],
glow_color=meta["glow_color"],
icon=meta["icon"],
tasks=buckets[quadrant],
)
for quadrant, meta in QUADRANT_META.items()
]
def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut:
counts = ScheduleCenterCommanderSummaryOut()
for task in tasks:
states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)]
for state in states:
normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE)
if normalized == TaskDispatchStatus.IDLE:
continue
counts.total += 1
if normalized == TaskDispatchStatus.QUEUED:
counts.queued += 1
elif normalized == TaskDispatchStatus.RUNNING:
counts.running += 1
elif normalized == TaskDispatchStatus.COMPLETED:
counts.completed += 1
elif normalized == TaskDispatchStatus.FAILED:
counts.failed += 1
if counts.running > 0:
counts.overall_status = "running"
elif counts.queued > 0:
counts.overall_status = "queued"
elif counts.failed > 0 and counts.completed == 0:
counts.overall_status = "failed"
return counts
@router.get("/month", response_model=ScheduleCenterMonthOut)
async def get_month_schedule(
year: int = Query(..., ge=2000, le=2100),
@@ -53,27 +230,43 @@ async def get_month_schedule(
start_dt = datetime.combine(month_start, datetime.min.time())
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
todos = (await db.execute(
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
)).scalars().all()
tasks = (await db.execute(
todos = (
await db.execute(
select(DailyTodo).where(
DailyTodo.user_id == current_user.id,
DailyTodo.todo_date >= start_key,
DailyTodo.todo_date <= end_key,
)
)
).scalars().all()
tasks = (
await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
)).scalars().all()
reminders = (await db.execute(
)
).scalars().all()
reminders = (
await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
)).scalars().all()
goals = (await db.execute(
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
)).scalars().all()
)
).scalars().all()
goals = (
await db.execute(
select(Goal).where(
Goal.user_id == current_user.id,
Goal.goal_date >= start_key,
Goal.goal_date <= end_key,
)
)
).scalars().all()
todo_map: dict[str, list[DailyTodo]] = {}
for item in todos:
@@ -96,18 +289,20 @@ async def get_month_schedule(
days = []
for day in range(1, days_in_month + 1):
date_key = month_start.replace(day=day).isoformat()
days.append(_build_summary(
days.append(
_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
))
)
)
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
@router.get("/date", response_model=ScheduleCenterDateOut)
@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True)
async def get_date_schedule(
date_str: date = Query(...),
current_user: User = Depends(get_current_user),
@@ -118,22 +313,28 @@ async def get_date_schedule(
end_dt = datetime.combine(target_date, datetime.max.time())
date_key = target_date.isoformat()
todos = (await db.execute(
todos = (
await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)).scalars().all()
tasks = (await db.execute(
)
).scalars().all()
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
.order_by(Task.created_at.desc())
)).scalars().all()
reminders = (await db.execute(
.order_by(Task.priority.desc(), Task.created_at.desc())
)
).scalars().unique().all()
reminders = (
await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
@@ -141,20 +342,26 @@ async def get_date_schedule(
Reminder.reminder_at <= end_dt,
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)).scalars().all()
goals = (await db.execute(
)
).scalars().all()
goals = (
await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)).scalars().all()
)
).scalars().all()
summary = _build_summary(date_key, todos, tasks, reminders, goals)
return ScheduleCenterDateOut(
date=date_key,
todos=todos,
tasks=tasks,
tasks=[build_task_out(task) for task in tasks],
reminders=reminders,
goals=goals,
summary=summary,
focus_tasks=_build_focus_tasks(tasks),
quadrants=_build_quadrants(tasks),
commander_summary=_build_commander_summary(tasks),
generated_at=datetime.now(UTC),
)

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

@@ -7,3 +7,9 @@ router = APIRouter(prefix='/api/system', tags=['system'])
@router.get('/status')
async def get_system_status():
return SystemService().get_status()
@router.get('/config')
async def get_system_config():
"""Get public system configuration."""
return await SystemService().get_config()

View File

@@ -1,15 +1,116 @@
import json
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.task import Task, TaskStatus
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.task import TaskCreate, TaskUpdate, TaskOut
from app.schemas.task import (
TaskCreate,
TaskDetailOut,
TaskDispatchRequest,
TaskDispatchResponse,
TaskHistoryOut,
TaskOut,
TaskSubTaskCreate,
TaskSubTaskOut,
TaskSubTaskReorderRequest,
TaskSubTaskUpdate,
TaskUpdate,
build_task_detail_out,
)
from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch
router = APIRouter(prefix="/api/tasks", tags=["看板"])
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
def _encode_tags(tags: list[str] | None) -> str | None:
if not tags:
return None
return json.dumps(tags, ensure_ascii=False)
def _decode_tags(value: str | None) -> list[str]:
if not value:
return []
try:
payload = json.loads(value)
except json.JSONDecodeError:
return [value]
if isinstance(payload, list):
return [str(item) for item in payload]
return [str(payload)]
def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut:
return TaskSubTaskOut.model_validate(subtask)
def _history_to_out(history) -> TaskHistoryOut:
return TaskHistoryOut.model_validate(history)
def _task_to_out(task: Task) -> TaskOut:
return TaskOut(
id=task.id,
title=task.title,
description=task.description,
status=task.status,
priority=task.priority,
due_date=task.due_date,
completed_at=task.completed_at,
tags=_decode_tags(task.tags),
source=task.source or TaskSource.MANUAL,
conversation_id=task.conversation_id,
quadrant=task.quadrant,
assignee_type=task.assignee_type,
assignee_id=task.assignee_id,
dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE,
dispatch_run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
created_at=task.created_at,
updated_at=task.updated_at,
)
def _task_detail_to_out(task: Task) -> TaskDetailOut:
return build_task_detail_out(task)
async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task:
task = await load_task_with_details(db, task_id=task_id, user_id=user_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
def _sync_task_completion(task: Task) -> None:
if task.status == TaskStatus.DONE:
task.completed_at = task.completed_at or datetime.now(UTC)
elif task.status != TaskStatus.CANCELLED:
task.completed_at = None
def _sync_subtask_completion(subtask: TaskSubTask) -> None:
if subtask.status == TaskStatus.DONE:
subtask.completed_at = subtask.completed_at or datetime.now(UTC)
elif subtask.status != TaskStatus.CANCELLED:
subtask.completed_at = None
@router.get("", response_model=list[TaskOut])
@@ -18,12 +119,28 @@ async def list_tasks(
due_date: date | None = Query(default=None),
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
quadrant: TaskQuadrant | None = None,
assignee_type: TaskAssigneeType | None = None,
dispatch_status: TaskDispatchStatus | None = None,
conversation_id: str | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Task).where(Task.user_id == current_user.id)
query = (
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(Task.user_id == current_user.id)
)
if status:
query = query.where(Task.status == status)
if quadrant:
query = query.where(Task.quadrant == quadrant)
if assignee_type:
query = query.where(Task.assignee_type == assignee_type)
if dispatch_status:
query = query.where(Task.dispatch_status == dispatch_status)
if conversation_id:
query = query.where(Task.conversation_id == conversation_id)
if due_date:
start = datetime.combine(due_date, datetime.min.time())
end = datetime.combine(due_date, datetime.max.time())
@@ -32,65 +149,109 @@ async def list_tasks(
start = datetime.combine(date_from, datetime.min.time()) if date_from else None
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
if start and end and start > end:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
raise HTTPException(status_code=400, detail="date_from cannot be later than date_to")
if start is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
if end is not None:
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
query = query.order_by(desc(Task.created_at))
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
result = await db.execute(query)
return result.scalars().all()
tasks = result.scalars().unique().all()
return [_task_to_out(task) for task in tasks]
@router.post("", response_model=TaskOut, status_code=201)
@router.post("", response_model=TaskDetailOut, status_code=201)
async def create_task(
data: TaskCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
import json
task = Task(
user_id=current_user.id,
title=data.title,
description=data.description,
priority=data.priority,
due_date=data.due_date,
tags=json.dumps(data.tags) if data.tags else None,
tags=_encode_tags(data.tags),
source=data.source,
conversation_id=data.conversation_id,
quadrant=data.quadrant,
assignee_type=data.assignee_type,
assignee_id=data.assignee_id,
status=data.status,
)
_sync_task_completion(task)
if data.source == TaskSource.CHAT:
append_task_history(task, action="created_from_chat", new_value=task.title)
append_task_history(task, action="created", new_value=task.title)
for index, subtask_data in enumerate(data.subtasks):
subtask = TaskSubTask(
title=subtask_data.title,
description=subtask_data.description,
status=subtask_data.status,
order_index=index if subtask_data.order_index is None else subtask_data.order_index,
assignee_type=subtask_data.assignee_type,
assignee_id=subtask_data.assignee_id,
)
_sync_subtask_completion(subtask)
task.subtasks.append(subtask)
append_task_history(task, action="subtask_created", new_value=subtask.title)
db.add(task)
await db.commit()
await db.refresh(task)
return task
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
if data.dispatch_to_commander:
await queue_task_dispatch(task, db=db)
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.patch("/{task_id}", response_model=TaskOut)
@router.get("/{task_id}", response_model=TaskDetailOut)
async def get_task(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.patch("/{task_id}", response_model=TaskDetailOut)
async def update_task(
task_id: str,
data: TaskUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
import json
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
payload = data.model_dump(exclude_none=True)
previous_assignee = (task.assignee_type, task.assignee_id)
for field, value in data.model_dump(exclude_none=True).items():
for field, value in payload.items():
previous = getattr(task, field)
if field == "tags":
setattr(task, field, json.dumps(value))
elif field == "status" and value == TaskStatus.DONE:
task.completed_at = datetime.now(UTC)
setattr(task, field, value)
elif field == "status":
task.completed_at = None
task.tags = _encode_tags(value)
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
continue
setattr(task, field, value)
if field == "status":
_sync_task_completion(task)
append_task_history(task, action="status_changed", old_value=previous, new_value=value)
elif previous != value:
append_task_history(task, action="updated", old_value=previous, new_value=value)
if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id):
append_task_history(
task,
action="assigned",
old_value=f"{previous_assignee[0]}:{previous_assignee[1]}",
new_value=f"{task.assignee_type}:{task.assignee_id}",
)
await db.commit()
await db.refresh(task)
return task
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.delete("/{task_id}", status_code=204)
@@ -99,11 +260,171 @@ async def delete_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
await db.delete(task)
await db.commit()
@router.post("/{task_id}/subtasks", status_code=201)
async def create_subtask(
task_id: str,
data: TaskSubTaskCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
max_order = max((item.order_index for item in task.subtasks), default=-1)
subtask = TaskSubTask(
task_id=task.id,
title=data.title,
description=data.description,
status=data.status,
order_index=max_order + 1 if data.order_index is None else data.order_index,
assignee_type=data.assignee_type,
assignee_id=data.assignee_id,
)
_sync_subtask_completion(subtask)
task.subtasks.append(subtask)
append_task_history(task, action="subtask_created", new_value=data.title)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
detail = _task_detail_to_out(task)
created_subtask = max(
(item for item in detail.subtasks if item.title == data.title),
key=lambda item: (item.order_index, item.created_at),
default=None,
)
if created_subtask is None:
raise HTTPException(status_code=500, detail="Created subtask could not be loaded")
return {
**created_subtask.model_dump(),
"task": detail.model_dump(),
"subtasks": [item.model_dump() for item in detail.subtasks],
"history": [item.model_dump() for item in detail.history],
"dispatch": detail.dispatch.model_dump(),
"dispatch_summary": detail.dispatch_summary.model_dump(),
}
@router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
async def update_subtask(
task_id: str,
subtask_id: str,
data: TaskSubTaskUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
payload = data.model_dump(exclude_none=True)
for field, value in payload.items():
previous = getattr(subtask, field)
setattr(subtask, field, value)
if field == "status":
_sync_subtask_completion(subtask)
if previous != value:
append_task_history(
task,
action="updated" if field != "status" else "status_changed",
old_value=f"{subtask.id}:{field}:{previous}",
new_value=f"{subtask.id}:{field}:{value}",
)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
async def delete_subtask(
task_id: str,
subtask_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title)
await db.delete(subtask)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut)
async def reorder_subtasks(
task_id: str,
data: TaskSubTaskReorderRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
lookup = {item.id: item for item in task.subtasks}
for item in data.items:
subtask = lookup.get(item.id)
if subtask is None:
raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}")
subtask.order_index = item.order_index
append_task_history(
task,
action="subtask_reordered",
new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items),
)
await db.commit()
db.expire_all()
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return _task_detail_to_out(task)
@router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse)
async def dispatch_task(
task_id: str,
data: TaskDispatchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.target != "commander":
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
_, payload = await queue_task_dispatch(task, db=db)
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return TaskDispatchResponse(
status=task.dispatch_status,
run_id=task.dispatch_run_id,
task=_task_detail_to_out(task),
payload=payload,
)
@router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse)
async def dispatch_subtask(
task_id: str,
subtask_id: str,
data: TaskDispatchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if data.target != "commander":
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
if subtask is None:
raise HTTPException(status_code=404, detail="Subtask not found")
_, payload = await queue_task_dispatch(task, db=db, subtask=subtask)
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
return TaskDispatchResponse(
status=subtask.dispatch_status,
run_id=subtask.dispatch_run_id,
task=_task_detail_to_out(task),
payload=payload,
)

View File

@@ -0,0 +1,348 @@
"""Tools API Router
聚合两套工具体系的元数据:
1. 注册层 (app/tools/) - YAML manifest 定义
2. Agent 层 (app/agents/tools/) - @tool 装饰器定义
"""
import re
import importlib
from fastapi import APIRouter, Depends
from app.routers.auth import get_current_user
from app.models.user import User
from app.schemas.tools import (
ToolsResponse,
ToolCategory,
ToolSubgroup,
ToolInfo,
ToolCommand,
ToolStats,
ToolSummary,
)
router = APIRouter(prefix="/api/tools", tags=["Tools"])
# ============================================================
# 辅助函数
# ============================================================
def _parse_command_from_docstring(docstring: str) -> dict:
"""从函数的 docstring 解析参数信息"""
params = {"type": "object", "properties": {}, "required": []}
if not docstring:
return params
# 简单解析 Args: 段落
args_match = re.search(
r"Args:\s*(.*?)(?=\n\s*(?:Returns?|Raises?)|$", docstring, re.DOTALL | re.IGNORECASE
)
if args_match:
args_section = args_match.group(1)
# 匹配形如 "arg_name (type): description" 的行
for line in args_section.strip().split("\n"):
line = line.strip()
if not line:
continue
# 匹配: "name (type): description" 或 "name: description"
m = re.match(r"(\w+)\s*(?:\(\s*(\w+)\s*\))?\s*:", line)
if m:
param_name = m.group(1)
params["properties"][param_name] = {"type": "string", "description": line}
params["required"].append(param_name)
return params
def _build_agent_tools() -> list[ToolInfo]:
"""扫描 app/agents/tools/ 目录,内省 @tool 装饰器"""
tools: list[ToolInfo] = []
# 分类映射:文件名 -> (分类名, 子分类名)
category_map = {
"search": ("Agent层", "知识检索"),
"schedule": ("Agent层", "日程管理"),
"task": ("Agent层", "任务管理"),
"forum": ("Agent层", "论坛功能"),
"time_reasoning": ("Agent层", "时间推理"),
"builtins/file_tools": ("Agent层", "文件工具"),
"builtins/system_tools": ("Agent层", "系统命令"),
"builtins/dev_tools": ("Agent层", "开发工具"),
"builtins/collaboration_tools": ("Agent层", "协作工具"),
}
# 工具名称 -> 中文显示名
display_names = {
"search_knowledge": "知识库搜索",
"get_knowledge_graph_context": "知识图谱查询",
"build_knowledge_graph": "构建知识图谱",
"hybrid_search": "混合搜索",
"web_search": "联网搜索",
"get_schedule_day": "获取日程",
"create_todo": "创建待办",
"create_schedule_task": "创建日程任务",
"create_reminder": "创建提醒",
"create_goal": "创建目标",
"get_tasks": "获取任务列表",
"create_task": "创建任务",
"update_task_status": "更新任务状态",
"get_forum_posts": "获取论坛帖子",
"create_forum_post": "发布论坛帖子",
"scan_forum_for_instructions": "扫描论坛指令",
"resolve_time_expression": "解析时间表达式",
"glob": "文件路径匹配",
"grep": "文件内容搜索",
"read_file": "读取文件",
"write_file": "写入文件",
"bash": "Bash命令",
"powershell": "PowerShell命令",
"git": "Git操作",
"lsp_tools": "LSP代码导航",
"team_agent": "团队Agent通信",
"task_broadcast": "任务广播",
}
# 工具描述
descriptions = {
"search_knowledge": "搜索用户的私人知识库,返回最相关的文档片段",
"get_knowledge_graph_context": "获取用户知识图谱的上下文信息",
"build_knowledge_graph": "从文档构建/更新知识图谱",
"hybrid_search": "混合搜索,结合向量语义检索和关键词匹配",
"web_search": "通过 SearxNG 搜索外部网页信息",
"get_schedule_day": "获取指定日期的 todo/task/reminder/goal 聚合信息",
"create_todo": "创建指定日期的待办",
"create_schedule_task": "创建任务,支持优先级和截止日期",
"create_reminder": "创建提醒,支持自然语言时间",
"create_goal": "创建指定日期的目标",
"get_tasks": "获取用户当前的任务列表",
"create_task": "创建新任务",
"update_task_status": "更新任务状态",
"get_forum_posts": "获取论坛帖子列表",
"create_forum_post": "在论坛发布新帖子",
"scan_forum_for_instructions": "扫描论坛中的指令类帖子",
"resolve_time_expression": "解析中文自然语言时间表达",
"glob": "使用 glob 模式查找文件路径",
"grep": "在文件中搜索匹配的文本行",
"read_file": "读取文件内容",
"write_file": "写入文件内容",
"bash": "执行 Bash 命令",
"powershell": "执行 PowerShell 命令",
"git": "执行 Git 命令",
"lsp_tools": "LSP 代码导航和查找引用",
"team_agent": "向团队 Agent 发送消息或请求协作",
"task_broadcast": "向多个 Agent 广播任务",
}
# 需要扫描的模块
modules_to_scan = [
("app.agents.tools.search", "search"),
("app.agents.tools.schedule", "schedule"),
("app.agents.tools.task", "task"),
("app.agents.tools.forum", "forum"),
("app.agents.tools.time_reasoning", "time_reasoning"),
("app.agents.tools.builtins.file_tools", "builtins/file_tools"),
("app.agents.tools.builtins.system_tools", "builtins/system_tools"),
("app.agents.tools.builtins.dev_tools", "builtins/dev_tools"),
("app.agents.tools.builtins.collaboration_tools", "builtins/collaboration_tools"),
]
for module_name, category_key in modules_to_scan:
try:
mod = importlib.import_module(module_name)
except ImportError:
continue
# 扫描模块中所有 @tool 装饰的函数
for attr_name in dir(mod):
if attr_name.startswith("_"):
continue
attr = getattr(mod, attr_name)
# 检查是否是 langchain @tool 装饰的对象
if hasattr(attr, "name") and hasattr(attr, "description"):
tool_name = attr.name
tool_desc = attr.description or ""
# 清理 docstring 中的参数说明用于显示
display_desc = re.sub(r"\s*Args:\s*.*", "", tool_desc, flags=re.DOTALL).strip()
display_desc = re.sub(
r"\s*Returns?:\s*.*", "", display_desc, flags=re.DOTALL
).strip()
# 获取 category 和 subcategory
cat_info = category_map.get(category_key, ("Agent层", category_key))
category, subcategory = cat_info[0], cat_info[1]
# 获取参数 schema
params_schema = getattr(attr, "args_schema", None)
parameters = {}
if params_schema:
try:
if hasattr(params_schema, "model_json_schema"):
parameters = params_schema.model_json_schema()
elif hasattr(params_schema, "schema"):
parameters = params_schema.schema()
except Exception:
pass
tool_info = ToolInfo(
name=tool_name,
display_name=display_names.get(tool_name, tool_name),
description=descriptions.get(tool_name, display_desc or tool_desc),
category=category,
subcategory=subcategory,
source="agent",
source_file=module_name,
tags=[],
enabled=True,
commands=[
ToolCommand(
name=tool_name,
description=tool_desc or display_desc,
parameters=parameters,
)
],
stats=ToolStats(),
)
tools.append(tool_info)
return tools
def _build_manifest_tools() -> list[ToolInfo]:
"""从 YAML manifest 构建工具信息"""
tools: list[ToolInfo] = []
# manifest 文件 -> 分类映射
manifest_map = {
"file_operator": (
"注册层",
"文件操作",
[
ToolCommand(name="read_file", description="读取指定路径的文件内容"),
ToolCommand(name="write_file", description="将内容写入文件"),
ToolCommand(name="list_directory", description="列出目录内容"),
ToolCommand(name="search_files", description="递归搜索匹配模式的文件"),
],
),
"task_manager": (
"注册层",
"任务管理",
[
ToolCommand(name="create_task", description="创建新任务"),
ToolCommand(name="list_tasks", description="列出任务"),
ToolCommand(name="get_task", description="获取任务详情"),
ToolCommand(name="complete_task", description="标记任务完成"),
ToolCommand(name="fail_task", description="标记任务失败"),
],
),
"web_fetch": (
"注册层",
"网页抓取",
[
ToolCommand(name="fetch", description="抓取网页内容"),
ToolCommand(name="screenshot", description="截取网页截图"),
],
),
"web_search": (
"注册层",
"联网搜索",
[
ToolCommand(name="search", description="执行语义级搜索"),
ToolCommand(name="deep_search", description="深度搜索,带摘要生成"),
],
),
}
manifest_descriptions = {
"file_operator": "强大的文件系统操作工具,支持读写、搜索、下载等功能",
"task_manager": "任务创建、查询、更新和状态管理",
"web_fetch": "网页内容抓取工具,支持 HTML 解析、截图等功能",
"web_search": "语义级并发搜索引擎,支持多源搜索和结果聚合",
}
for tool_name, (category, subcategory, commands) in manifest_map.items():
tool_info = ToolInfo(
name=tool_name,
display_name=subcategory,
description=manifest_descriptions.get(tool_name, ""),
category=category,
subcategory=subcategory,
source="manifest",
source_file=f"app/tools/manifests/{tool_name}.yaml",
tags=[],
enabled=True,
commands=commands,
stats=ToolStats(),
)
tools.append(tool_info)
return tools
# ============================================================
# 路由
# ============================================================
@router.get("", response_model=ToolsResponse)
async def list_tools(
current_user: User = Depends(get_current_user),
):
"""获取所有内置工具列表(只读)"""
# 构建工具列表
manifest_tools = _build_manifest_tools()
agent_tools = _build_agent_tools()
all_tools = manifest_tools + agent_tools
# 按 category 和 subcategory 分组
category_map: dict[str, dict[str, list[ToolInfo]]] = {
"注册层": {},
"Agent层": {},
}
for tool in all_tools:
cat = tool.category
subcat = tool.subcategory
if cat not in category_map:
category_map[cat] = {}
if subcat not in category_map[cat]:
category_map[cat][subcat] = []
category_map[cat][subcat].append(tool)
# 构建响应
categories = []
for cat_name, subgroups_dict in category_map.items():
if not subgroups_dict:
continue
subgroups = []
for subcat_name, tools_list in subgroups_dict.items():
subgroups.append(
ToolSubgroup(
name=subcat_name,
display_name=subcat_name,
tools=tools_list,
)
)
categories.append(
ToolCategory(
name=cat_name,
display_name=cat_name,
subgroups=subgroups,
)
)
# 计算摘要
total_commands = sum(len(t.commands) for t in all_tools)
active_commands = sum(len(t.commands) for t in all_tools if t.enabled)
summary = ToolSummary(
total_commands=total_commands,
active_commands=active_commands,
total_tools=len(all_tools),
manifest_tools=len(manifest_tools),
agent_tools=len(agent_tools),
)
return ToolsResponse(categories=categories, summary=summary)

View File

@@ -1,5 +1,7 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
class MessageCreate(BaseModel):
@@ -37,6 +39,7 @@ class ChatRequest(BaseModel):
conversation_id: str | None = None
agent_id: str | None = None
model_name: str | None = None
runtime: Literal["jarvis", "hermes"] | None = None
file_ids: list[str] = []

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl
class RemoteMountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
base_url: HttpUrl
username: str | None = Field(default=None, max_length=255)
password: str | None = Field(default=None, max_length=2000)
root_path: str = Field(default="/", min_length=1, max_length=1000)
class RemoteMountOut(BaseModel):
id: str
name: str
mount_type: str
base_url: str
username: str | None
root_path: str
is_active: bool
last_sync_at: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class RemoteNodeOut(BaseModel):
path: str
name: str
is_dir: bool
size: int | None = None
modified_at: str | None = None
etag: str | None = None
children: list["RemoteNodeOut"] = []
class RemoteMountTreeOut(BaseModel):
mount_id: str
root_path: str
nodes: list[RemoteNodeOut]
class RemoteSyncRequest(BaseModel):
remote_path: str = Field(..., min_length=1, max_length=2000)
local_folder_id: str = Field(..., min_length=1, max_length=36)
mode: str = Field(default="file", pattern="^(file|folder)$")
class RemoteSyncResultOut(BaseModel):
synced: int
skipped: int
failed: int
document_ids: list[str]
errors: list[str]
RemoteNodeOut.model_rebuild()

View File

@@ -1,7 +1,8 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.models.task import TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
from app.schemas.goal import GoalOut
from app.schemas.reminder import ReminderOut
from app.schemas.task import TaskOut
@@ -18,6 +19,47 @@ class ScheduleCenterDaySummary(BaseModel):
goal_total: int
class ScheduleCenterFocusTaskOut(BaseModel):
id: str
title: str
status: TaskStatus
priority: TaskPriority
quadrant: TaskQuadrant | None = None
assignee_type: str | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus
due_date: datetime | None = None
class ScheduleCenterQuadrantTaskOut(BaseModel):
id: str
title: str
status: TaskStatus
priority: TaskPriority
dispatch_status: TaskDispatchStatus
assignee_type: str | None = None
assignee_id: str | None = None
class ScheduleCenterQuadrantOut(BaseModel):
id: TaskQuadrant
title: str
subtitle: str
color: str
glow_color: str
icon: str
tasks: list[ScheduleCenterQuadrantTaskOut] = Field(default_factory=list)
class ScheduleCenterCommanderSummaryOut(BaseModel):
total: int = 0
queued: int = 0
running: int = 0
completed: int = 0
failed: int = 0
overall_status: str | None = None
class ScheduleCenterMonthOut(BaseModel):
month: str
days: list[ScheduleCenterDaySummary]
@@ -30,4 +72,9 @@ class ScheduleCenterDateOut(BaseModel):
reminders: list[ReminderOut]
goals: list[GoalOut]
summary: ScheduleCenterDaySummary
focus_tasks: list[ScheduleCenterFocusTaskOut] = Field(default_factory=list)
quadrants: list[ScheduleCenterQuadrantOut] = Field(default_factory=list)
commander_summary: ScheduleCenterCommanderSummaryOut = Field(
default_factory=ScheduleCenterCommanderSummaryOut,
)
generated_at: datetime

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

@@ -1,14 +1,146 @@
from pydantic import BaseModel
from __future__ import annotations
import json
from datetime import datetime
from app.models.task import TaskStatus, TaskPriority
from pydantic import BaseModel, Field
from sqlalchemy import inspect
from sqlalchemy.orm.attributes import NO_VALUE
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
def _coerce_enum(value, enum_cls, default=None):
if value is None:
return default
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return default
for item in enum_cls:
if raw == item.value or raw.lower() == item.value:
return item
if raw.upper() == item.name:
return item
return default
def parse_tags(raw_tags: str | None) -> list[str]:
if not raw_tags:
return []
try:
parsed = json.loads(raw_tags)
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
return [str(item) for item in parsed]
def serialize_tags(tags: list[str] | None) -> str | None:
if not tags:
return None
return json.dumps([str(item) for item in tags], ensure_ascii=False)
class TaskSubTaskCreate(BaseModel):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.TODO
order_index: int | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
class TaskSubTaskUpdate(BaseModel):
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
order_index: int | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus | None = None
dispatch_run_id: str | None = None
result_summary: str | None = None
class TaskSubTaskReorderItem(BaseModel):
id: str
order_index: int
class TaskSubTaskReorderRequest(BaseModel):
items: list[TaskSubTaskReorderItem] = Field(default_factory=list)
class TaskSubTaskOut(BaseModel):
id: str
task_id: str
title: str
description: str | None
status: TaskStatus
order_index: int
assignee_type: TaskAssigneeType | None
assignee_id: str | None
dispatch_status: TaskDispatchStatus
dispatch_run_id: str | None
result_summary: str | None = None
completed_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskHistoryOut(BaseModel):
id: str
task_id: str
action: str
old_value: str | None
new_value: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskDispatchSummary(BaseModel):
status: TaskDispatchStatus
run_id: str | None = None
result_summary: str | None = None
started_at: datetime | None = None
last_synced_at: datetime | None = None
total_subtasks: int = 0
dispatched_subtasks: int = 0
subtask_dispatch_statuses: dict[str, int] = Field(default_factory=dict)
class TaskCreate(BaseModel):
title: str
description: str | None = None
status: TaskStatus = TaskStatus.TODO
priority: TaskPriority = TaskPriority.MEDIUM
due_date: datetime | None = None
tags: list[str] | None = None
source: TaskSource = TaskSource.MANUAL
conversation_id: str | None = None
quadrant: TaskQuadrant | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
subtasks: list[TaskSubTaskCreate] = Field(default_factory=list)
dispatch_to_commander: bool = False
class TaskUpdate(BaseModel):
@@ -18,6 +150,16 @@ class TaskUpdate(BaseModel):
priority: TaskPriority | None = None
due_date: datetime | None = None
tags: list[str] | None = None
source: TaskSource | None = None
conversation_id: str | None = None
quadrant: TaskQuadrant | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
dispatch_status: TaskDispatchStatus | None = None
dispatch_run_id: str | None = None
result_summary: str | None = None
started_at: datetime | None = None
last_synced_at: datetime | None = None
class TaskOut(BaseModel):
@@ -28,12 +170,128 @@ class TaskOut(BaseModel):
priority: TaskPriority
due_date: datetime | None
completed_at: datetime | None
tags: str | None
tags: list[str] = Field(default_factory=list)
source: TaskSource
conversation_id: str | None
quadrant: TaskQuadrant | None
assignee_type: TaskAssigneeType | None
assignee_id: str | None
dispatch_status: TaskDispatchStatus
dispatch_run_id: str | None
result_summary: str | None
started_at: datetime | None
last_synced_at: datetime | None
subtask_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TaskDetailOut(TaskOut):
subtasks: list[TaskSubTaskOut] = Field(default_factory=list)
history: list[TaskHistoryOut] = Field(default_factory=list)
dispatch: TaskDispatchSummary
dispatch_summary: TaskDispatchSummary
class TaskDispatchRequest(BaseModel):
target: str = "commander"
conversation_id: str | None = None
assignee_type: TaskAssigneeType | None = None
assignee_id: str | None = None
class TaskDispatchResponse(BaseModel):
status: TaskDispatchStatus
run_id: str | None = None
task: TaskDetailOut
payload: dict[str, object] = Field(default_factory=dict)
class DailyPlanRequest(BaseModel):
user_id: str
def build_task_out(task: Task) -> TaskOut:
subtasks_attr = inspect(task).attrs.subtasks.loaded_value
return TaskOut(
id=task.id,
title=task.title,
description=task.description,
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
due_date=task.due_date,
completed_at=task.completed_at,
tags=parse_tags(task.tags),
source=_coerce_enum(task.source, TaskSource, TaskSource.MANUAL),
conversation_id=task.conversation_id,
quadrant=_coerce_enum(task.quadrant, TaskQuadrant, None),
assignee_type=_coerce_enum(task.assignee_type, TaskAssigneeType, None),
assignee_id=task.assignee_id,
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
dispatch_run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
subtask_count=0 if subtasks_attr is NO_VALUE else len(subtasks_attr or []),
created_at=task.created_at,
updated_at=task.updated_at,
)
def build_task_detail_out(task: Task) -> TaskDetailOut:
normalized_task_dispatch = _coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE)
normalized_subtasks = [
TaskSubTaskOut(
id=item.id,
task_id=item.task_id,
title=item.title,
description=item.description,
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
order_index=item.order_index,
assignee_type=_coerce_enum(item.assignee_type, TaskAssigneeType, None),
assignee_id=item.assignee_id,
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
dispatch_run_id=item.dispatch_run_id,
result_summary=item.result_summary,
completed_at=item.completed_at,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in task.subtasks
]
subtask_dispatch_statuses: dict[str, int] = {}
for item in normalized_subtasks:
key = item.dispatch_status.value
subtask_dispatch_statuses[key] = subtask_dispatch_statuses.get(key, 0) + 1
dispatched_subtasks = sum(1 for item in normalized_subtasks if item.dispatch_status != TaskDispatchStatus.IDLE)
return TaskDetailOut(
**build_task_out(task).model_dump(),
subtasks=normalized_subtasks,
history=[TaskHistoryOut.model_validate(item) for item in task.history],
dispatch=TaskDispatchSummary(
status=normalized_task_dispatch,
run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
total_subtasks=len(normalized_subtasks),
dispatched_subtasks=dispatched_subtasks,
subtask_dispatch_statuses=subtask_dispatch_statuses,
),
dispatch_summary=TaskDispatchSummary(
status=normalized_task_dispatch,
run_id=task.dispatch_run_id,
result_summary=task.result_summary,
started_at=task.started_at,
last_synced_at=task.last_synced_at,
total_subtasks=len(normalized_subtasks),
dispatched_subtasks=dispatched_subtasks,
subtask_dispatch_statuses=subtask_dispatch_statuses,
),
)

View File

@@ -0,0 +1,76 @@
"""Tools API Schemas"""
from pydantic import BaseModel
from typing import Optional
class ToolCommand(BaseModel):
"""单个工具命令"""
name: str
description: str
parameters: dict = {}
class ToolStats(BaseModel):
"""工具调用统计"""
call_count: int = 0
error_count: int = 0
total_duration_ms: int = 0
avg_duration_ms: int = 0
error_rate: float = 0.0
class ToolInfo(BaseModel):
"""工具完整信息"""
name: str
display_name: str
description: str
category: str # 中文分类名
subcategory: str = "" # 子分类
source: str # "manifest" | "agent"
source_file: str = "" # 来源文件路径
tags: list[str] = []
enabled: bool = True
commands: list[ToolCommand] = []
stats: Optional[ToolStats] = None
config: dict = {} # 配置参数(只读)
class ToolCategory(BaseModel):
"""工具分类"""
name: str # 大分类:注册层 / Agent层
display_name: str # 中文显示名
subgroups: list["ToolSubgroup"] = []
class ToolSubgroup(BaseModel):
"""工具子分类"""
name: str # 子分类名
display_name: str # 中文显示名
tools: list[ToolInfo] = []
class ToolSummary(BaseModel):
"""工具统计摘要"""
total_commands: int = 0
active_commands: int = 0
total_tools: int = 0
manifest_tools: int = 0
agent_tools: int = 0
class ToolsResponse(BaseModel):
"""GET /api/tools 响应"""
categories: list[ToolCategory]
summary: ToolSummary
# 更新前向引用
ToolCategory.model_rebuild()

View File

@@ -0,0 +1,9 @@
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter, hermes_runtime_adapter
from app.services.agent_runtime.jarvis_runtime import JarvisRuntimeAdapter, jarvis_runtime_adapter
__all__ = [
"HermesRuntimeAdapter",
"hermes_runtime_adapter",
"JarvisRuntimeAdapter",
"jarvis_runtime_adapter",
]

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Protocol
from app.models.conversation import Conversation, Message
from app.models.user import User
RuntimeName = str
@dataclass(slots=True)
class RuntimePreparedContext:
user: User
conversation: Conversation
user_message: Message
assistant_message: Message
raw_message: str
full_message: str
file_ids: list[str]
model_name: str | None
memory_context: str | None
class ChatRuntime(Protocol):
name: RuntimeName
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]: ...
async def chat_once(
self,
prepared: RuntimePreparedContext,
) -> tuple[str, str | None]: ...

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import asyncio
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, AsyncGenerator
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
class HermesRuntimeAdapter(ChatRuntime):
name = "hermes"
def __init__(self) -> None:
self._repo_path = Path(__file__).resolve().parents[4] / ".tmp" / "hermes-agent"
self._agent_class = None
def probe(self) -> dict[str, Any]:
cli_path = self._repo_path / "cli.py"
run_agent_path = self._repo_path / "run_agent.py"
return {
"repo_path": str(self._repo_path),
"repo_exists": self._repo_path.exists(),
"cli_exists": cli_path.exists(),
"run_agent_exists": run_agent_path.exists(),
"supports_single_query": True,
"supports_resume": True,
"integration_mode": "python_ai_agent_bridge",
}
def _load_agent_class(self):
if self._agent_class is not None:
return self._agent_class
run_agent_path = self._repo_path / "run_agent.py"
if not run_agent_path.exists():
raise RuntimeError(f"Hermes run_agent.py 未找到: {run_agent_path}")
repo_path = str(self._repo_path)
if repo_path not in sys.path:
sys.path.insert(0, repo_path)
spec = importlib.util.spec_from_file_location("jarvis_hermes_run_agent", run_agent_path)
if spec is None or spec.loader is None:
raise RuntimeError("无法加载 Hermes run_agent 模块")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self._agent_class = getattr(module, "AIAgent")
return self._agent_class
def _build_agent(self, prepared: RuntimePreparedContext, session_id: str):
agent_class = self._load_agent_class()
kwargs: dict[str, Any] = {
"session_id": session_id,
"platform": "jarvis",
"user_id": prepared.user.id,
"quiet_mode": True,
"persist_session": True,
"skip_context_files": True,
"max_iterations": 30,
}
if prepared.model_name:
kwargs["model"] = prepared.model_name
return agent_class(**kwargs)
def _build_system_message(self, prepared: RuntimePreparedContext) -> str:
parts = [
"You are Hermes running inside the Jarvis chat runtime.",
"Return normal assistant text for the user. Do not mention internal bridge details unless asked.",
]
if prepared.memory_context:
parts.append(prepared.memory_context)
return "\n\n".join(parts)
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]:
handle = hermes_session_manager.get_or_create(
conversation_id=prepared.conversation.id,
user_id=prepared.user.id,
)
async with handle.lock:
yield {
"type": "progress",
"stage": "planning",
"label": "Hermes 正在准备会话",
"agent": "hermes",
"step": "加载 Hermes runtime",
"steps": [
"恢复会话上下文",
"调用 Hermes AIAgent",
"回传流式回复",
],
}
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
loop = asyncio.get_running_loop()
result_box: dict[str, Any] = {"content": None, "error": None, "model": prepared.model_name or "hermes"}
def stream_callback(delta: str) -> None:
loop.call_soon_threadsafe(queue.put_nowait, {"type": "chunk", "content": delta})
def run_sync() -> None:
try:
agent = self._build_agent(prepared, handle.hermes_session_id)
result = agent.run_conversation(
prepared.full_message,
system_message=self._build_system_message(prepared),
stream_callback=stream_callback,
)
result_box["content"] = str(result.get("final_response") or "")
result_box["model"] = getattr(agent, "model", prepared.model_name or "hermes")
except Exception as exc: # pragma: no cover - surfaced through queue
result_box["error"] = f"Hermes 执行失败: {exc}"
loop.call_soon_threadsafe(
queue.put_nowait,
{"type": "error", "error": result_box["error"]},
)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
worker = asyncio.create_task(asyncio.to_thread(run_sync))
streamed_text = ""
while True:
event = await queue.get()
if event is None:
break
if event.get("type") == "chunk":
streamed_text += str(event.get("content", ""))
yield event
await worker
handle.last_used_at = datetime.now(UTC)
handle.metadata = {
"session_id": handle.hermes_session_id,
"model": result_box["model"],
"last_error": result_box["error"],
}
final_text = result_box["content"] or streamed_text
if final_text and final_text != streamed_text:
yield {"type": "chunk", "content": final_text}
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
handle = hermes_session_manager.get_or_create(
conversation_id=prepared.conversation.id,
user_id=prepared.user.id,
)
async with handle.lock:
agent = await asyncio.to_thread(self._build_agent, prepared, handle.hermes_session_id)
result = await asyncio.to_thread(
agent.run_conversation,
prepared.full_message,
self._build_system_message(prepared),
)
handle.last_used_at = datetime.now(UTC)
resolved_model = getattr(agent, "model", prepared.model_name or "hermes")
handle.metadata = {
"session_id": handle.hermes_session_id,
"model": resolved_model,
"last_error": None,
}
return str(result.get("final_response") or ""), resolved_model
hermes_runtime_adapter = HermesRuntimeAdapter()

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@dataclass(slots=True)
class HermesSessionHandle:
conversation_id: str
user_id: str
hermes_session_id: str
last_used_at: datetime = field(default_factory=lambda: datetime.now(UTC))
restart_count: int = 0
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
metadata: dict[str, Any] = field(default_factory=dict)
class HermesSessionManager:
def __init__(self) -> None:
self._sessions: dict[str, HermesSessionHandle] = {}
def get_or_create(self, *, conversation_id: str, user_id: str) -> HermesSessionHandle:
handle = self._sessions.get(conversation_id)
if handle is None:
handle = HermesSessionHandle(
conversation_id=conversation_id,
user_id=user_id,
hermes_session_id=f"jarvis-{conversation_id}",
)
self._sessions[conversation_id] = handle
handle.last_used_at = datetime.now(UTC)
return handle
hermes_session_manager = HermesSessionManager()

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
from app.services.agent_runtime.base import ChatRuntime, RuntimePreparedContext
class JarvisRuntimeAdapter(ChatRuntime):
name = "jarvis"
async def chat_stream(
self,
prepared: RuntimePreparedContext,
) -> AsyncGenerator[dict[str, Any], None]:
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
async def chat_once(self, prepared: RuntimePreparedContext) -> tuple[str, str | None]:
raise NotImplementedError("Jarvis runtime is executed inside AgentService")
jarvis_runtime_adapter = JarvisRuntimeAdapter()

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,12 +22,29 @@ 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
from app.services.agent_runtime.base import RuntimePreparedContext
from app.services.agent_runtime.hermes_runtime import hermes_runtime_adapter
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
logger = logging.getLogger(__name__)
@@ -36,6 +54,7 @@ MEMORY_SECTION_HEADERS = (
"【之前对话摘要】",
"【知识大脑】",
)
MEMORY_INLINE_HEADERS = {"[关于你的记忆]"}
def _split_memory_context_sections(memory_context: str | None) -> dict[str, str]:
@@ -81,6 +100,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()
@@ -327,6 +381,9 @@ class AgentService:
def __init__(self, db: AsyncSession):
self.db = db
def _resolve_runtime(self, runtime: str | None) -> str:
return runtime or "jarvis"
async def _try_auto_summarize_background(self, user_id: str, conversation_id: str) -> None:
async with async_session() as session:
await memory_service.try_auto_summarize(session, user_id, conversation_id)
@@ -461,18 +518,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 +548,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,
@@ -492,10 +668,12 @@ class AgentService:
conversation_id: str | None = None,
file_ids: list[str] | None = None,
model_name: str | None = None,
runtime: str | None = None,
) -> tuple[str, str, AsyncGenerator[dict[str, Any], None]]:
"""
处理对话请求(流式)
"""
runtime_name = self._resolve_runtime(runtime)
user_llm_config = await self._get_user_llm_config(user_id, model_name)
model_name_used = model_name
if model_name and not user_llm_config:
@@ -588,7 +766,7 @@ class AgentService:
conversation_id=conversation_id,
role="assistant",
content="",
model=model_name_used or "jarvis",
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
attachments=None,
)
self.db.add(assistant_msg)
@@ -603,28 +781,113 @@ class AgentService:
"title": "Assistant message",
"content_summary": content[:500],
"raw_excerpt": content[:2000],
"metadata_": {"role": "assistant"},
"metadata_": {"role": "assistant", "runtime": runtime_name},
"importance_signal": 0.8,
}
if runtime_name == "hermes":
user = await self.db.get(User, user_id)
if user is None:
raise ValueError("用户不存在")
prepared = RuntimePreparedContext(
user=user,
conversation=conv,
user_message=user_msg,
assistant_message=assistant_msg,
raw_message=message,
full_message=full_message,
file_ids=file_ids or [],
model_name=model_name_used,
memory_context=memory_ctx,
)
async def run_hermes():
collected = ""
stream_failed = False
try:
async for event in hermes_runtime_adapter.chat_stream(prepared):
if event.get("type") == "chunk":
collected += str(event.get("content", ""))
elif event.get("type") == "error":
stream_failed = True
yield event
finally:
try:
session_handle = hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
)
assistant_msg.content = collected if collected else ("Hermes 执行失败,请检查运行配置。" if stream_failed else "")
assistant_msg.model = str(session_handle.metadata.get("model") or "hermes")
assistant_msg.attachments = [
{
"kind": "runtime_info",
"runtime": "hermes",
"session_id": session_handle.hermes_session_id,
"model": session_handle.metadata.get("model"),
"last_error": session_handle.metadata.get("last_error"),
}
]
conv.agent_state = {
"runtime": "hermes",
"runtime_state": {
"hermes": {
"session_id": session_handle.hermes_session_id,
"message_id": assistant_msg.id,
"model": session_handle.metadata.get("model"),
"last_error": session_handle.metadata.get("last_error"),
}
},
}
await BrainService(self.db).create_event(
user_id,
**_build_assistant_event_payload(assistant_msg.content),
)
await self.db.commit()
await self.db.refresh(assistant_msg)
except Exception:
logger.exception("save_hermes_assistant_message_failed")
asyncio.create_task(self._try_auto_summarize_background(user_id, conversation_id))
asyncio.create_task(self._extract_memories_background(user_id, conversation_id))
return conversation_id, assistant_msg.id, run_hermes()
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 +1012,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,7 +1020,25 @@ 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 = (
{
@@ -771,8 +1052,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))
@@ -788,10 +1079,12 @@ class AgentService:
conversation_id: str | None = None,
file_ids: list[str] | None = None,
model_name: str | None = None,
runtime: str | None = None,
) -> tuple[str, str, str, str | None]:
"""
简单同步版对话
"""
runtime_name = self._resolve_runtime(runtime)
user_llm_config = await self._get_user_llm_config(user_id, model_name)
model_name_used = model_name
if model_name and not user_llm_config:
@@ -828,7 +1121,7 @@ class AgentService:
conversation_id=conversation_id,
role="assistant",
content="",
model=model_name_used or "jarvis",
model=(model_name_used or "jarvis") if runtime_name == "jarvis" else runtime_name,
attachments=None,
)
self.db.add(assistant_msg)
@@ -857,20 +1150,100 @@ class AgentService:
if recall_ctx:
memory_ctx = f"{memory_ctx}\n{recall_ctx}" if memory_ctx else recall_ctx
if runtime_name == "hermes":
user = await self.db.get(User, user_id)
if user is None:
raise ValueError("用户不存在")
prepared = RuntimePreparedContext(
user=user,
conversation=conv,
user_message=user_msg,
assistant_message=assistant_msg,
raw_message=message,
full_message=message,
file_ids=file_ids or [],
model_name=model_name_used,
memory_context=memory_ctx,
)
response_content, resolved_model_name = await hermes_runtime_adapter.chat_once(prepared)
assistant_msg.content = response_content
assistant_msg.model = resolved_model_name or "hermes"
assistant_msg.attachments = [{
"kind": "runtime_info",
"runtime": "hermes",
"session_id": hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
).hermes_session_id,
"model": resolved_model_name,
}]
conv.agent_state = {
"runtime": "hermes",
"runtime_state": {
"hermes": {
"session_id": hermes_session_manager.get_or_create(
conversation_id=conv.id,
user_id=user_id,
).hermes_session_id,
"message_id": assistant_msg.id,
"model": resolved_model_name,
}
},
}
await brain_service.create_event(
user_id,
source_type="conversation",
source_id=conversation_id,
event_type="message_created",
title="Assistant message",
content_summary=response_content[:500],
raw_excerpt=response_content[:2000],
metadata_={"role": "assistant", "runtime": "hermes"},
importance_signal=0.8,
)
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=None,
)
return conversation_id, assistant_msg.id, response_content, assistant_msg.model
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=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 +1273,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,7 +1281,25 @@ 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 = (
{
@@ -918,7 +1309,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

@@ -5,6 +5,7 @@
from pathlib import Path
import tempfile
import shutil
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import UploadFile
@@ -18,7 +19,6 @@ import json
import os
import re
import aiofiles
import uuid
from dataclasses import dataclass, field
@@ -52,9 +52,9 @@ class DocumentService:
if ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"不支持的文件类型: {ext}")
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
file_id = str(uuid.uuid4())
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
folder_path = await self._get_storage_directory(user_id, folder_id)
folder_path.mkdir(parents=True, exist_ok=True)
file_path = self._resolve_unique_file_path(folder_path, file.filename)
content = await file.read()
file_size = len(content)
@@ -64,7 +64,7 @@ class DocumentService:
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
parsed = await self._parse_document(file_path, ext)
parsed = await self._parse_document(str(file_path), ext)
parsed.structured_markdown = self._render_structured_markdown(parsed)
doc = Document(
@@ -73,7 +73,7 @@ class DocumentService:
filename=file.filename,
file_type=ext[1:],
file_size=file_size,
file_path=file_path,
file_path=str(file_path),
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
folder_id=folder_id,
ingestion_status="uploaded",
@@ -171,6 +171,83 @@ class DocumentService:
return "/" + "/".join(path_parts) if path_parts else None
async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path:
folder_path = await self._get_storage_directory(user_id, folder_id)
folder_path.mkdir(parents=True, exist_ok=True)
return folder_path
async def delete_folder_directory(self, user_id: str, folder_id: str) -> None:
folder_path = await self._get_storage_directory(user_id, folder_id)
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)
async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None:
folder = await self.db.get(Folder, folder_id)
if folder is None:
return
parent_path = await self._get_storage_directory(user_id, folder.parent_id)
old_path = parent_path / self._sanitize_storage_name(old_name)
new_path = parent_path / self._sanitize_storage_name(new_name)
if old_path != new_path:
parent_path.mkdir(parents=True, exist_ok=True)
if old_path.exists():
old_path.rename(new_path)
else:
new_path.mkdir(parents=True, exist_ok=True)
else:
new_path.mkdir(parents=True, exist_ok=True)
document_result = await self.db.execute(
select(Document).where(Document.user_id == user_id)
)
for document in document_result.scalars().all():
try:
relative_path = Path(document.file_path).relative_to(old_path)
except ValueError:
continue
document.file_path = str(new_path / relative_path)
async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path:
base_path = Path(settings.UPLOAD_DIR) / user_id
if not folder_id:
return base_path
folders = await self.db.execute(
select(Folder).where(Folder.user_id == user_id)
)
folder_map = {folder.id: folder for folder in folders.scalars().all()}
path_segments: list[str] = []
current_id = folder_id
while current_id:
folder = folder_map.get(current_id)
if folder is None:
raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?")
path_segments.insert(0, self._sanitize_storage_name(folder.name))
current_id = folder.parent_id
return base_path.joinpath(*path_segments)
def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path:
safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True)
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
candidate = directory / safe_name
counter = 2
while candidate.exists():
candidate = directory / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str:
invalid_chars = '<>:"/\\|?*'
sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.')
if not sanitized:
return 'untitled' if is_file else 'folder'
return sanitized
async def delete_document(self, user_id: str, document_id: str):
result = await self.db.execute(
select(Document).where(

View File

@@ -0,0 +1,108 @@
from io import BytesIO
from datetime import UTC, datetime
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.datastructures import UploadFile
from app.models.folder import Folder
from app.models.remote_mount import RemoteMount, RemoteSyncItem
from app.services.document_service import DocumentService
from app.services.webdav_service import WebDavNode, WebDavService
class RemoteSyncService:
def __init__(self, db: AsyncSession, user_id: str):
self.db = db
self.user_id = user_id
async def sync_remote_path(
self,
mount: RemoteMount,
remote_path: str,
local_folder_id: str,
mode: str = "file",
) -> dict:
folder = await self.db.execute(
select(Folder).where(and_(Folder.id == local_folder_id, Folder.user_id == self.user_id))
)
if folder.scalar_one_or_none() is None:
raise ValueError("本地目标文件夹不存在")
webdav = WebDavService(mount)
document_service = DocumentService(self.db, self.user_id)
synced = 0
skipped = 0
failed = 0
document_ids: list[str] = []
errors: list[str] = []
if mode == "folder":
nodes = await webdav.list_tree(remote_path)
targets = self._flatten_files(nodes)
else:
name = remote_path.rstrip("/").split("/")[-1] or "remote-file"
targets = [WebDavNode(path=remote_path, name=name, is_dir=False)]
for node in targets:
try:
content, filename = await webdav.download_file(node.path)
upload = UploadFile(filename=filename, file=BytesIO(content))
document = await document_service.upload_document(self.user_id, upload, folder_id=local_folder_id)
await self._upsert_sync_item(mount.id, node, local_folder_id, document.id)
document_ids.append(document.id)
synced += 1
except Exception as exc: # noqa: BLE001
failed += 1
errors.append(f"{node.path}: {exc}")
await self._upsert_sync_item(mount.id, node, local_folder_id, None, status="failed", error=str(exc))
mount.last_sync_at = datetime.now(UTC).isoformat()
await self.db.commit()
return {
"synced": synced,
"skipped": skipped,
"failed": failed,
"document_ids": document_ids,
"errors": errors,
}
def _flatten_files(self, nodes: list[WebDavNode]) -> list[WebDavNode]:
results: list[WebDavNode] = []
for node in nodes:
if node.is_dir:
results.extend(self._flatten_files(node.children))
else:
results.append(node)
return results
async def _upsert_sync_item(
self,
mount_id: str,
node: WebDavNode,
local_folder_id: str,
local_document_id: str | None,
status: str = "synced",
error: str | None = None,
) -> None:
result = await self.db.execute(
select(RemoteSyncItem).where(
and_(RemoteSyncItem.mount_id == mount_id, RemoteSyncItem.remote_path == node.path)
)
)
sync_item = result.scalar_one_or_none()
if sync_item is None:
sync_item = RemoteSyncItem(
mount_id=mount_id,
remote_path=node.path,
)
self.db.add(sync_item)
sync_item.remote_etag = node.etag
sync_item.remote_modified_at = node.modified_at
sync_item.local_folder_id = local_folder_id
sync_item.local_document_id = local_document_id
sync_item.sync_status = status
sync_item.last_error = error
sync_item.last_synced_at = datetime.now(UTC).isoformat()

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

@@ -0,0 +1,24 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from app.config import settings
def _build_fernet() -> Fernet:
digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8")

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

@@ -1,8 +1,12 @@
from datetime import datetime, UTC
from time import monotonic
import os
import platform
import socket
import subprocess
import time
import httpx
try:
import psutil
@@ -14,6 +18,15 @@ 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
from app.config import settings
self._settings = settings
def _get_network_rates(self) -> tuple[float, float]:
counters = psutil.net_io_counters()
@@ -127,3 +140,96 @@ class SystemService:
**gpu_status,
'timestamp': datetime.now(UTC).isoformat(),
}
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': location,
**weather,
'weather_cached': False,
'weather_cached_at': now,
}

View File

@@ -0,0 +1,238 @@
import asyncio
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import object_session
from sqlalchemy.orm import selectinload
from app.models.task import (
Task,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskStatus,
TaskSubTask,
)
def _now() -> datetime:
return datetime.now(UTC)
def _stringify(value: object | None) -> str | None:
if value is None:
return None
return str(value)
def append_task_history(
task: Task,
*,
action: str,
old_value: object | None = None,
new_value: object | None = None,
) -> None:
entry = TaskHistory(
task_id=task.id,
action=action,
old_value=_stringify(old_value),
new_value=_stringify(new_value),
)
session = object_session(task)
if session is not None:
session.add(entry)
return
task.history.append(entry)
def build_dispatch_payload(task: Task, subtasks: list[TaskSubTask]) -> dict[str, object]:
return {
"business_task_id": task.id,
"title": task.title,
"description": task.description,
"priority": task.priority.value if isinstance(task.priority, TaskPriority) else str(task.priority),
"due_date": task.due_date.isoformat() if task.due_date else None,
"conversation_id": task.conversation_id,
"user_id": task.user_id,
"subtasks": [
{
"id": item.id,
"title": item.title,
"description": item.description,
"status": item.status.value if isinstance(item.status, TaskStatus) else str(item.status),
"assignee_type": item.assignee_type.value if item.assignee_type else None,
"assignee_id": item.assignee_id,
"dispatch_status": (
item.dispatch_status.value
if isinstance(item.dispatch_status, TaskDispatchStatus)
else str(item.dispatch_status)
),
"order_index": item.order_index,
}
for item in subtasks
],
}
async def _run_dispatch_flow(
task_id: str,
run_id: str,
*,
session_factory,
subtask_id: str | None = None,
) -> None:
await asyncio.sleep(0.01)
async with session_factory() as db:
task = await db.get(Task, task_id)
if task is None:
return
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
if subtask_id and target is None:
return
if subtask_id:
previous = target.dispatch_status
target.dispatch_status = TaskDispatchStatus.RUNNING
target.dispatch_run_id = run_id
target.completed_at = None
task.dispatch_status = TaskDispatchStatus.RUNNING
task.dispatch_run_id = run_id
task.started_at = task.started_at or _now()
task.last_synced_at = _now()
append_task_history(
task,
action="dispatch_status_changed",
old_value=f"{subtask_id}:{previous.value}",
new_value=f"{subtask_id}:{TaskDispatchStatus.RUNNING.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.RUNNING
task.dispatch_run_id = run_id
task.started_at = task.started_at or _now()
task.last_synced_at = _now()
task.status = TaskStatus.IN_PROGRESS
append_task_history(
task,
action="dispatch_status_changed",
old_value=previous.value,
new_value=TaskDispatchStatus.RUNNING.value,
)
await db.commit()
await asyncio.sleep(0.01)
async with session_factory() as db:
task = await db.get(Task, task_id)
if task is None:
return
target = await db.get(TaskSubTask, subtask_id) if subtask_id else None
if subtask_id and target is None:
return
synced_at = _now()
if subtask_id:
previous = target.dispatch_status
target.dispatch_status = TaskDispatchStatus.COMPLETED
target.dispatch_run_id = run_id
target.status = TaskStatus.DONE
target.completed_at = synced_at
task.dispatch_status = TaskDispatchStatus.COMPLETED
task.dispatch_run_id = run_id
task.result_summary = f"Commander completed subtask {target.title}"
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatch_status_changed",
old_value=f"{subtask_id}:{previous.value}",
new_value=f"{subtask_id}:{TaskDispatchStatus.COMPLETED.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.COMPLETED
task.dispatch_run_id = run_id
task.result_summary = f"Commander completed task {task.title}"
task.last_synced_at = synced_at
task.status = TaskStatus.DONE
task.completed_at = synced_at
append_task_history(
task,
action="dispatch_status_changed",
old_value=previous.value,
new_value=TaskDispatchStatus.COMPLETED.value,
)
await db.commit()
def schedule_dispatch(task_id: str, run_id: str, *, session_factory, subtask_id: str | None = None) -> None:
asyncio.create_task(
_run_dispatch_flow(
task_id,
run_id,
session_factory=session_factory,
subtask_id=subtask_id,
)
)
async def queue_task_dispatch(
task: Task,
*,
db,
subtask: TaskSubTask | None = None,
) -> tuple[str, dict[str, object]]:
subtasks = list(task.subtasks)
run_id = uuid4().hex[:12]
synced_at = _now()
if subtask is not None:
previous = subtask.dispatch_status
subtask.dispatch_status = TaskDispatchStatus.QUEUED
subtask.dispatch_run_id = run_id
task.dispatch_status = TaskDispatchStatus.QUEUED
task.dispatch_run_id = run_id
task.result_summary = None
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatched_to_commander",
old_value=f"{subtask.id}:{previous.value}",
new_value=f"{subtask.id}:{TaskDispatchStatus.QUEUED.value}",
)
else:
previous = task.dispatch_status
task.dispatch_status = TaskDispatchStatus.QUEUED
task.dispatch_run_id = run_id
task.result_summary = None
task.started_at = None
task.last_synced_at = synced_at
append_task_history(
task,
action="dispatched_to_commander",
old_value=previous.value,
new_value=TaskDispatchStatus.QUEUED.value,
)
await db.commit()
await db.refresh(task)
payload = build_dispatch_payload(task, subtasks)
session_factory = async_sessionmaker(bind=db.bind, expire_on_commit=False)
schedule_dispatch(
task.id,
run_id,
session_factory=session_factory,
subtask_id=subtask.id if subtask else None,
)
return run_id, payload
async def load_task_with_details(db, *, task_id: str, user_id: str) -> Task | None:
result = await db.execute(
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(Task.id == task_id, Task.user_id == user_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,127 @@
from dataclasses import dataclass, field
from urllib.parse import quote, urljoin
import xml.etree.ElementTree as ET
import httpx
from app.models.remote_mount import RemoteMount
from app.services.secret_service import decrypt_secret
WEBDAV_NAMESPACE = {
"d": "DAV:",
}
@dataclass
class WebDavNode:
path: str
name: str
is_dir: bool
size: int | None = None
modified_at: str | None = None
etag: str | None = None
children: list["WebDavNode"] = field(default_factory=list)
class WebDavService:
def __init__(self, mount: RemoteMount):
self.mount = mount
self.username = mount.username or None
self.password = decrypt_secret(mount.password_encrypted)
def _normalize_remote_path(self, remote_path: str | None = None) -> str:
path = remote_path or self.mount.root_path or "/"
if not path.startswith("/"):
path = f"/{path}"
return path
def _build_url(self, remote_path: str | None = None) -> str:
path = self._normalize_remote_path(remote_path)
encoded = "/".join(quote(segment) for segment in path.split("/") if segment)
if not encoded:
return self.mount.base_url.rstrip("/") + "/"
return urljoin(self.mount.base_url.rstrip("/") + "/", encoded)
async def list_directory(self, remote_path: str | None = None) -> list[WebDavNode]:
path = self._normalize_remote_path(remote_path)
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname />
<d:resourcetype />
<d:getcontentlength />
<d:getlastmodified />
<d:getetag />
</d:prop>
</d:propfind>"""
async with httpx.AsyncClient(timeout=30.0, auth=self._auth()) as client:
response = await client.request(
"PROPFIND",
self._build_url(path),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=body,
)
response.raise_for_status()
return self._parse_propfind(path, response.text)
async def list_tree(self, remote_path: str | None = None, max_depth: int = 4) -> list[WebDavNode]:
path = self._normalize_remote_path(remote_path)
nodes = await self.list_directory(path)
if max_depth <= 1:
return nodes
for node in nodes:
if node.is_dir:
node.children = await self.list_tree(node.path, max_depth=max_depth - 1)
return nodes
async def download_file(self, remote_path: str) -> tuple[bytes, str]:
normalized = self._normalize_remote_path(remote_path)
async with httpx.AsyncClient(timeout=120.0, auth=self._auth()) as client:
response = await client.get(self._build_url(normalized))
response.raise_for_status()
name = normalized.rstrip("/").split("/")[-1] or "remote-file"
return response.content, name
def _auth(self) -> httpx.BasicAuth | None:
if self.username and self.password:
return httpx.BasicAuth(self.username, self.password)
return None
def _parse_propfind(self, parent_path: str, payload: str) -> list[WebDavNode]:
root = ET.fromstring(payload)
nodes: list[WebDavNode] = []
for response in root.findall("d:response", WEBDAV_NAMESPACE):
href = response.findtext("d:href", default="", namespaces=WEBDAV_NAMESPACE)
if not href:
continue
normalized_href = "/" + href.split("://", 1)[-1].split("/", 1)[-1].strip("/")
normalized_href = "/" if normalized_href == "/" else normalized_href.rstrip("/")
normalized_parent = self._normalize_remote_path(parent_path).rstrip("/") or "/"
if normalized_href.rstrip("/") == normalized_parent.rstrip("/"):
continue
prop = response.find("d:propstat/d:prop", WEBDAV_NAMESPACE)
if prop is None:
continue
is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NAMESPACE) is not None
display_name = prop.findtext("d:displayname", default="", namespaces=WEBDAV_NAMESPACE) or normalized_href.split("/")[-1]
size_text = prop.findtext("d:getcontentlength", default="", namespaces=WEBDAV_NAMESPACE)
etag = prop.findtext("d:getetag", default=None, namespaces=WEBDAV_NAMESPACE)
modified_at = prop.findtext("d:getlastmodified", default=None, namespaces=WEBDAV_NAMESPACE)
nodes.append(WebDavNode(
path=normalized_href,
name=display_name,
is_dir=is_dir,
size=int(size_text) if size_text.isdigit() else None,
etag=etag,
modified_at=modified_at,
))
nodes.sort(key=lambda item: (not item.is_dir, item.name.lower()))
return nodes

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

@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
import app.models # noqa: F401
from app.database import Base
from app.models.document import Document, DocumentChunk
from app.models.folder import Folder
from app.models.user import User
from app.services.auth_service import get_password_hash
from app.services.document_service import DocumentService
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
@pytest.mark.asyncio
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
session, user = document_test_env
service = DocumentService(session)
root = Folder(user_id=user.id, name='Projects')
session.add(root)
await session.flush()
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
session.add(child)
await session.commit()
await session.refresh(child)
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
document = await service.upload_document(user.id, upload, folder_id=child.id)
file_path = Path(document.file_path)
assert file_path.name == 'system-design.md'
assert file_path.parent.name == 'Specs'
assert file_path.parent.parent.name == 'Projects'
assert file_path.exists()
@pytest.mark.asyncio
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
session, user = document_test_env

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.agent_runtime.base import RuntimePreparedContext
from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter
from app.services.agent_runtime.hermes_session_manager import hermes_session_manager
class FakeAgent:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.model = kwargs.get("model", "fake-hermes-model")
def run_conversation(self, user_message, system_message=None, stream_callback=None):
if stream_callback is not None:
stream_callback("hello ")
stream_callback("world")
return {"final_response": "hello world"}
@pytest.fixture(autouse=True)
def clear_hermes_sessions():
hermes_session_manager._sessions.clear()
yield
hermes_session_manager._sessions.clear()
@pytest.fixture
def prepared_context():
return RuntimePreparedContext(
user=SimpleNamespace(id="user-1"),
conversation=SimpleNamespace(id="conv-1"),
user_message=SimpleNamespace(id="msg-user"),
assistant_message=SimpleNamespace(id="msg-assistant"),
raw_message="hi",
full_message="hi",
file_ids=[],
model_name="hermes-test-model",
memory_context="memory block",
)
@pytest.mark.asyncio
async def test_chat_once_calls_ai_agent(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
content, model = await adapter.chat_once(prepared_context)
assert content == "hello world"
assert model == "hermes-test-model"
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None
@pytest.mark.asyncio
async def test_chat_stream_emits_progress_and_chunks(monkeypatch, prepared_context):
adapter = HermesRuntimeAdapter()
monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent)
events = []
async for event in adapter.chat_stream(prepared_context):
events.append(event)
assert events[0]["type"] == "progress"
chunks = [event["content"] for event in events if event["type"] == "chunk"]
assert "hello " in chunks
assert "world" in chunks
handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1")
assert handle.metadata["model"] == "hermes-test-model"
assert handle.metadata["last_error"] is None

View File

@@ -0,0 +1,39 @@
from app.models.remote_mount import RemoteMount
from app.services.secret_service import encrypt_secret
from app.services.webdav_service import WebDavService
def test_parse_propfind_returns_sorted_nodes():
mount = RemoteMount(
user_id='user-1',
name='Docs',
mount_type='webdav',
base_url='https://example.com/dav/',
username='alice',
password_encrypted=encrypt_secret('secret'),
root_path='/knowledge',
is_active=True,
)
payload = """<?xml version="1.0" encoding="utf-8" ?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/knowledge/</d:href>
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/specs/</d:href>
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/knowledge/roadmap.md</d:href>
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
</d:response>
</d:multistatus>"""
nodes = WebDavService(mount)._parse_propfind('/knowledge', payload)
assert [node.name for node in nodes] == ['specs', 'roadmap.md']
assert nodes[0].is_dir is True
assert nodes[1].is_dir is False
assert nodes[1].size == 128
assert nodes[1].etag == '"etag-1"'

View File

@@ -73,3 +73,87 @@ 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
@pytest.mark.asyncio
async def test_chat_stream_passes_runtime_to_agent_service(conversation_env, monkeypatch):
recorded: dict[str, object] = {}
async def fake_chat(self, **kwargs):
recorded.update(kwargs)
async def empty_stream():
if False:
yield None
return 'conv-rt', 'msg-rt', empty_stream()
monkeypatch.setattr('app.routers.conversation.AgentService.chat', fake_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', 'runtime': 'hermes'},
)
assert response.status_code == 200
assert recorded['runtime'] == 'hermes'
@pytest.mark.asyncio
async def test_chat_defaults_agent_name_to_jarvis(conversation_env, monkeypatch):
async def fake_chat_simple(self, **kwargs):
return 'conv-id', 'msg-id', 'ok', 'test-model'
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat',
json={'message': 'hello'},
)
assert response.status_code == 200
assert response.json()['agent_name'] == 'jarvis'
@pytest.mark.asyncio
async def test_chat_returns_hermes_agent_name_when_requested(conversation_env, monkeypatch):
async def fake_chat_simple(self, **kwargs):
return 'conv-id', 'msg-id', 'ok', 'hermes-model'
monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple)
transport = ASGITransport(app=conversation_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post(
'/api/conversations/chat',
json={'message': 'hello', 'runtime': 'hermes'},
)
assert response.status_code == 200
assert response.json()['agent_name'] == 'hermes'

View File

@@ -0,0 +1,182 @@
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, ensure_task_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()
@pytest.mark.anyio
async def test_ensure_task_columns_adds_today_status_columns_and_subtask_table(tmp_path):
db_path = tmp_path / 'test_tasks.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
async with engine.begin() as conn:
await conn.execute(text(
'''
CREATE TABLE tasks (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL,
priority VARCHAR(20) NOT NULL,
due_date DATETIME,
completed_at DATETIME,
tags VARCHAR(1000),
created_at DATETIME,
updated_at DATETIME
)
'''
))
await conn.execute(text(
'''
CREATE TABLE task_histories (
id VARCHAR(36) PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
action VARCHAR(100) NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME,
updated_at DATETIME
)
'''
))
await ensure_task_columns(conn)
task_columns = await conn.execute(text("PRAGMA table_info(tasks)"))
task_column_names = {row[1] for row in task_columns.fetchall()}
assert 'source' in task_column_names
assert 'conversation_id' in task_column_names
assert 'quadrant' in task_column_names
assert 'assignee_type' in task_column_names
assert 'dispatch_status' in task_column_names
assert 'dispatch_run_id' in task_column_names
assert 'last_synced_at' in task_column_names
history_columns = await conn.execute(text("PRAGMA table_info(task_histories)"))
history_column_names = {row[1] for row in history_columns.fetchall()}
assert 'subtask_id' in history_column_names
subtask_columns = await conn.execute(text("PRAGMA table_info(task_subtasks)"))
subtask_column_names = {row[1] for row in subtask_columns.fetchall()}
assert 'task_id' in subtask_column_names
assert 'order_index' in subtask_column_names
assert 'dispatch_status' in subtask_column_names
indexes = await conn.execute(text("PRAGMA index_list(task_subtasks)"))
index_names = {row[1] for row in indexes.fetchall()}
assert 'ix_task_subtasks_task_id' in index_names
assert 'ix_task_subtasks_dispatch_status' in index_names
await engine.dispose()

View File

@@ -0,0 +1,90 @@
from httpx import ASGITransport, AsyncClient
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401
from app.database import Base, get_db
from app.main import app
from app.models.folder import Folder
from app.models.user import User
from app.routers.auth import get_current_user
from app.services.auth_service import get_password_hash
@pytest.fixture
async def folder_router_env(tmp_path, monkeypatch):
db_path = tmp_path / 'test_folders_router.db'
upload_dir = tmp_path / 'uploads'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
user = User(
email='folders@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Folder Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir))
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield user, upload_dir, session_factory
finally:
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_create_folder_creates_matching_local_directory(folder_router_env):
user, upload_dir, _session_factory = folder_router_env
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None})
assert response.status_code == 201
folder_id = response.json()['id']
expected_path = upload_dir / user.id / 'Projects'
assert expected_path.exists()
assert expected_path.is_dir()
assert folder_id
@pytest.mark.asyncio
async def test_rename_folder_moves_local_directory(folder_router_env):
user, upload_dir, session_factory = folder_router_env
async with session_factory() as session:
folder = Folder(user_id=user.id, name='Old', parent_id=None)
session.add(folder)
await session.commit()
await session.refresh(folder)
(upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'})
assert response.status_code == 200
assert not (upload_dir / user.id / 'Old').exists()
assert (upload_dir / user.id / 'New').exists()

View File

@@ -1,10 +1,11 @@
import sys
from datetime import UTC, date, datetime
from datetime import UTC, datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
sys.modules.setdefault('psutil', Mock())
@@ -13,7 +14,7 @@ import app.models # noqa: F401
from app.database import Base, get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority, TaskStatus
from app.models.task import DispatchStatus, Task, TaskPriority, TaskQuadrant, TaskStatus, TaskSubTask
from app.models.todo import DailyTodo, TodoSource
from app.models.user import User
from app.routers.auth import get_current_user
@@ -50,7 +51,7 @@ async def schedule_env(tmp_path):
session.add_all([user, other_user])
await session.flush()
session.add_all([
seeded_items = [
DailyTodo(
user_id=user.id,
title='Legacy todo',
@@ -78,13 +79,19 @@ async def schedule_env(tmp_path):
title='High priority task',
priority=TaskPriority.HIGH,
status=TaskStatus.TODO,
source='schedule_center',
quadrant=TaskQuadrant.URGENT_IMPORTANT,
due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC),
assignee_type='commander',
assignee_id='master',
dispatch_status=DispatchStatus.RUNNING,
),
Task(
user_id=user.id,
title='Urgent task next day',
priority=TaskPriority.URGENT,
status=TaskStatus.IN_PROGRESS,
quadrant=TaskQuadrant.NOT_URGENT_IMPORTANT,
due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC),
),
Task(
@@ -106,6 +113,30 @@ async def schedule_env(tmp_path):
note='Ship MVP',
goal_date='2026-04-10',
),
]
session.add_all(seeded_items)
await session.flush()
high_priority_task = next(item for item in seeded_items if isinstance(item, Task) and item.title == 'High priority task')
session.add_all([
TaskSubTask(
task_id=high_priority_task.id,
title='Commander follow-up',
status=TaskStatus.TODO,
order_index=0,
assignee_type='agent',
assignee_id='executor',
dispatch_status=DispatchStatus.QUEUED,
),
TaskSubTask(
task_id=high_priority_task.id,
title='Commander completed step',
status=TaskStatus.DONE,
order_index=1,
assignee_type='agent',
assignee_id='executor',
dispatch_status=DispatchStatus.COMPLETED,
completed_at=datetime(2026, 4, 10, 16, 0, tzinfo=UTC),
),
])
await session.commit()
await session.refresh(user)
@@ -211,10 +242,143 @@ async def test_get_schedule_center_date_returns_aggregated_resources(schedule_en
'reminder_total': 1,
'goal_total': 1,
}
assert [item['title'] for item in payload['focus_tasks']] == ['High priority task']
assert [item['id'] for item in payload['quadrants']] == [
'urgent-important',
'not-urgent-important',
'urgent-not-important',
'not-urgent-not-important',
]
assert payload['quadrants'][0]['tasks'][0]['title'] == 'High priority task'
assert payload['commander_summary'] == {
'total': 3,
'queued': 1,
'running': 1,
'completed': 1,
'failed': 0,
'overall_status': 'running',
}
assert [item['title'] for item in payload['reminders']] == ['Doctor reminder']
assert [item['title'] for item in payload['goals']] == ['Launch calendar beta']
@pytest.mark.asyncio
async def test_task_detail_and_subtask_crud(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Plan Today Status',
'description': 'Wire task detail and quadrants',
'priority': 'high',
'quadrant': 'urgent-important',
'source': 'today_status',
'assignee_type': 'commander',
},
)
task_id = create_response.json()['id']
subtask_create = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Model backend', 'assignee_type': 'executor'},
)
subtask_id = subtask_create.json()['id']
subtask_update = await client.patch(
f'/api/tasks/{task_id}/subtasks/{subtask_id}',
json={'status': 'done'},
)
detail_response = await client.get(f'/api/tasks/{task_id}')
reorder_response = await client.post(
f'/api/tasks/{task_id}/subtasks/reorder',
json={'items': [{'id': subtask_id, 'order_index': 0}]},
)
assert create_response.status_code == 201
assert subtask_create.status_code == 201
assert subtask_update.status_code == 200
assert detail_response.status_code == 200
assert reorder_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload['source'] == 'today_status'
assert detail_payload['quadrant'] == 'urgent-important'
assert detail_payload['subtasks'][0]['title'] == 'Model backend'
assert detail_payload['subtasks'][0]['status'] == 'done'
assert any(entry['action'] == 'subtask_created' for entry in detail_payload['history'])
@pytest.mark.asyncio
async def test_task_dispatch_updates_summary_and_detail(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={'title': 'Dispatch me', 'priority': 'urgent', 'due_date': '2026-04-10T09:00:00Z'},
)
task_id = create_response.json()['id']
dispatch_response = await client.post(
f'/api/tasks/{task_id}/dispatch',
json={'assignee_type': 'commander', 'note': 'Send to runtime'},
)
detail_response = await client.get(f'/api/tasks/{task_id}')
assert dispatch_response.status_code == 200
dispatch_payload = dispatch_response.json()
assert dispatch_payload['status'] == 'queued'
assert dispatch_payload['run_id']
detail_payload = detail_response.json()
assert detail_payload['dispatch_status'] == 'queued'
assert detail_payload['dispatch_summary']['status'] == 'queued'
assert any(entry['action'] == 'dispatched_to_commander' for entry in detail_payload['history'])
@pytest.mark.asyncio
async def test_schedule_center_created_task_is_visible_in_today_status_aggregate(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Schedule Center created task',
'source': 'schedule_center',
'priority': 'medium',
'quadrant': 'not-urgent-important',
'due_date': '2026-04-10T09:00:00Z',
},
)
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert create_response.status_code == 201
titles = [item['title'] for item in date_response.json()['tasks']]
assert 'Schedule Center created task' in titles
focus_titles = [item['title'] for item in date_response.json()['focus_tasks']]
assert 'Schedule Center created task' in focus_titles
@pytest.mark.asyncio
async def test_today_status_created_task_is_visible_in_schedule_center_aggregate(schedule_env):
transport = ASGITransport(app=schedule_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Today Status created task',
'source': 'today_status',
'priority': 'high',
'quadrant': 'urgent-important',
'due_date': '2026-04-10T11:00:00Z',
},
)
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert create_response.status_code == 201
quadrant_titles = [
task['title']
for quadrant in date_response.json()['quadrants']
for task in quadrant['tasks']
]
assert 'Today Status created task' in quadrant_titles
@pytest.mark.asyncio
async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
transport = ASGITransport(app=schedule_env)
@@ -239,6 +403,65 @@ async def test_get_schedule_center_month_returns_day_summaries(schedule_env):
assert day_11['high_priority_total'] == 1
@pytest.mark.asyncio
async def test_schedule_center_tolerates_legacy_lowercase_task_enum_values(schedule_env):
app = schedule_env
session_override = app.dependency_overrides[get_db]
async for session in session_override():
user_id = (
await session.execute(
text("SELECT id FROM users WHERE email = 'schedule@example.com'")
)
).scalar_one()
await session.execute(
text(
"""
INSERT INTO tasks (
id, created_at, updated_at, user_id, title, description,
status, priority, due_date, completed_at, tags, source,
conversation_id, quadrant, assignee_type, assignee_id,
dispatch_status, dispatch_run_id, result_summary, started_at, last_synced_at
) VALUES (
:id, :created_at, :updated_at, :user_id, :title, NULL,
:status, :priority, :due_date, NULL, NULL, :source,
NULL, :quadrant, :assignee_type, :assignee_id,
:dispatch_status, NULL, NULL, NULL, NULL
)
"""
),
{
'id': 'legacy-task-1',
'created_at': '2026-04-10 06:00:00',
'updated_at': '2026-04-10 06:00:00',
'user_id': user_id,
'title': 'Legacy lowercase enum task',
'status': 'todo',
'priority': 'high',
'due_date': '2026-04-10 09:00:00',
'source': 'manual',
'quadrant': 'urgent-important',
'assignee_type': 'commander',
'assignee_id': 'legacy-master',
'dispatch_status': 'queued',
},
)
await session.commit()
break
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
month_response = await client.get('/api/schedule-center/month', params={'year': 2026, 'month': 4})
assert date_response.status_code == 200
assert month_response.status_code == 200
date_payload = date_response.json()
month_payload = month_response.json()
assert 'Legacy lowercase enum task' in [item['title'] for item in date_payload['tasks']]
assert next(item for item in month_payload['days'] if item['date'] == '2026-04-10')['task_due_total'] == 2
@pytest.mark.asyncio
async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env):
transport = ASGITransport(app=schedule_env)

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',
}

View File

@@ -0,0 +1,221 @@
import asyncio
import sys
from datetime import UTC, date, datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
sys.modules.setdefault('psutil', Mock())
import app.models # noqa: F401
from app.database import Base, get_db
from app.models.user import User
from app.routers.auth import get_current_user
from app.routers.schedule_center import router as schedule_center_router
from app.routers.task import router as task_router
from app.services.auth_service import get_password_hash
@pytest.fixture
async def task_env(tmp_path):
db_path = tmp_path / 'test_task_router.db'
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as session:
user = User(
username='task_user',
email='task@example.com',
hashed_password=get_password_hash('secret123'),
full_name='Task Tester',
)
session.add(user)
await session.commit()
await session.refresh(user)
async def override_get_db():
async with session_factory() as session:
yield session
async def override_get_current_user():
return user
test_app = FastAPI()
test_app.include_router(task_router)
test_app.include_router(schedule_center_router)
test_app.dependency_overrides[get_db] = override_get_db
test_app.dependency_overrides[get_current_user] = override_get_current_user
try:
yield test_app
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_create_task_and_get_detail_with_business_fields(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': 'Prepare daily status',
'description': 'Assemble the updated today status payload',
'priority': 'high',
'due_date': '2026-04-10T09:00:00Z',
'tags': ['today-status', 'chat'],
'source': 'chat',
'conversation_id': 'conv-123',
'quadrant': 'urgent-important',
'assignee_type': 'commander',
'assignee_id': 'code_commander',
},
)
assert create_response.status_code == 201
created = create_response.json()
detail_response = await client.get(f"/api/tasks/{created['id']}")
assert detail_response.status_code == 200
payload = detail_response.json()
assert payload['title'] == 'Prepare daily status'
assert payload['tags'] == ['today-status', 'chat']
assert payload['source'] == 'chat'
assert payload['conversation_id'] == 'conv-123'
assert payload['quadrant'] == 'urgent-important'
assert payload['assignee_type'] == 'commander'
assert payload['assignee_id'] == 'code_commander'
assert payload['dispatch']['status'] == 'idle'
assert [item['action'] for item in payload['history'][:2]] == ['created_from_chat', 'created']
@pytest.mark.asyncio
async def test_subtask_crud_and_reorder(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Implement kanban detail',
'due_date': '2026-04-10T11:00:00Z',
'quadrant': 'not-urgent-important',
},
)
task_id = task_response.json()['id']
first_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Load task detail', 'assignee_type': 'agent', 'assignee_id': 'planner'},
)
second_subtask = await client.post(
f'/api/tasks/{task_id}/subtasks',
json={'title': 'Persist task edits', 'assignee_type': 'agent', 'assignee_id': 'executor'},
)
first_id = first_subtask.json()['subtasks'][0]['id']
second_id = second_subtask.json()['subtasks'][1]['id']
update_response = await client.patch(
f'/api/tasks/{task_id}/subtasks/{first_id}',
json={'status': 'done'},
)
reorder_response = await client.post(
f'/api/tasks/{task_id}/subtasks/reorder',
json={'items': [{'id': first_id, 'order_index': 1}, {'id': second_id, 'order_index': 0}]},
)
assert update_response.status_code == 200
assert reorder_response.status_code == 200
reordered = reorder_response.json()
assert [item['id'] for item in reordered['subtasks']] == [second_id, first_id]
assert reordered['subtasks'][1]['status'] == 'done'
assert any(item['action'] == 'subtask_reordered' for item in reordered['history'])
@pytest.mark.asyncio
async def test_create_today_status_task_persists_status_and_subtasks(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
create_response = await client.post(
'/api/tasks',
json={
'title': '今日看板任务',
'description': '来自今日状态看板',
'status': 'in_progress',
'priority': 'high',
'source': 'today_status',
'quadrant': 'urgent-important',
'subtasks': [
{'title': '子任务一', 'status': 'todo'},
{'title': '子任务二', 'status': 'done'},
],
},
)
assert create_response.status_code == 201
payload = create_response.json()
detail_response = await client.get(f"/api/tasks/{payload['id']}")
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload['status'] == 'in_progress'
assert detail_payload['source'] == 'today_status'
assert detail_payload['quadrant'] == 'urgent-important'
assert [item['title'] for item in detail_payload['subtasks']] == ['子任务一', '子任务二']
assert detail_payload['subtasks'][0]['order_index'] == 0
assert detail_payload['subtasks'][1]['order_index'] == 1
assert detail_payload['subtasks'][1]['status'] == 'done'
assert detail_payload['subtasks'][1]['completed_at'] is not None
@pytest.mark.asyncio
async def test_dispatch_updates_task_and_schedule_center_summary(task_env):
transport = ASGITransport(app=task_env)
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
task_response = await client.post(
'/api/tasks',
json={
'title': 'Dispatch to commander',
'priority': 'urgent',
'due_date': '2026-04-10T15:00:00Z',
'quadrant': 'urgent-important',
},
)
task_id = task_response.json()['id']
dispatch_response = await client.post(
f'/api/tasks/{task_id}/dispatch',
json={'target': 'commander'},
)
assert dispatch_response.status_code == 200
assert dispatch_response.json()['task']['dispatch']['status'] == 'queued'
await asyncio.sleep(0.18)
detail_response = await client.get(f'/api/tasks/{task_id}')
date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'})
assert detail_response.status_code == 200
detail = detail_response.json()
assert detail['dispatch']['status'] == 'completed'
assert detail['status'] == 'done'
assert detail['dispatch']['run_id']
assert detail['dispatch']['result_summary']
assert date_response.status_code == 200
payload = date_response.json()
assert payload['commander_summary'] == {
'total': 1,
'queued': 0,
'running': 0,
'completed': 1,
'failed': 0,
}
assert payload['focus_tasks'][0]['id'] == task_id
quadrants = {item['id']: item for item in payload['quadrants']}
assert quadrants['urgent-important']['tasks'][0]['id'] == task_id

Binary file not shown.

View File

@@ -0,0 +1,86 @@
# 2026-04-07 工作日志
## 今日开发计划
### 今日目标
- 梳理 chat 页面左侧日历与 conversation session 的关系
- 明确“按日期点击切换 session”的改造方向
- 记录当前 session 机制与后续实现方案
### 今日计划拆分
1. 盘点当前 conversation session 的数据结构与切换逻辑
2. 确认 session 当前不是按“天”进行切分
3. 设计日历点击后的 session 切换方案
4. 将方案记录到 daily作为后续改造依据
---
## 今日实际完成
- 检查了前端 `conversation store``conversation api` 与后端 `conversation router / model`
- 确认当前 conversation session 以 `conversation_id` 为核心,不是按“天”自动切分
- 确认现有字段主要依赖 `created_at` / `updated_at` 做时间记录,但单个 session 可跨多天持续使用
- 明确了左侧日历点击切换 session 的推荐改造方式
---
## 当前结论
### session 现状
- 当前 session 不是以“天”为单位计算
- 当前会话列表来源于 `/api/conversations`,按 `updated_at` 倒序展示
- 点击某一天时,不能直接假定“一天对应一个现成 session”
### 推荐改造方案
采用“**保留现有 conversation 结构 + 前端增加按日期筛选/映射**”的方案:
1. 保持后端 `Conversation` / `Message` 结构不变
2. 前端基于 `created_at``updated_at` 将 conversations 映射到具体日期
3. 左侧日历某天被点击后,优先切换到该日期最近一次活跃的 session
4. 如果该日期没有 session则进入新会话态必要时再创建新的 conversation
5. 会话本质仍是 conversation不强制把数据库层改成“每天一个 session”
### 这样处理的原因
- 不需要重做现有 conversation 数据模型
- 不会破坏当前多轮上下文连续性
- 可以快速给日历交互增加“按日期查看/切换”能力
- 后续如果要做“每日会话视图”或“按天归档”也更容易扩展
---
## 建议的后续实现点
1. 在前端增加“选中日期”状态
2. 将 conversations 按日期建立索引映射
3. 日历点击时,根据日期找到对应 session 并调用 `selectConversation`
4. 如果无匹配 session则清空当前消息区并进入新会话态
5. 视需要补充“当天无会话”的空态提示
---
## 风险与临时决策
### 风险
- 如果一个 conversation 跨多天活跃,按 `created_at` 还是 `updated_at` 归属日期需要统一规则
- 如果同一天有多个 session需要定义点击日历后的优先选择策略
### 当前临时决策
- 先按 `updated_at` 作为日历映射依据,更符合“最近活跃”的使用直觉
- 先选择该日期下最近活跃的一条 conversation 作为默认切换目标
- 暂不改数据库,不引入“每天强制新建 session”的硬规则
---
## 下一步计划
1. 在 chat 页整理日历点击事件接入点
2. 补充 conversations 与日期映射的前端计算逻辑
3. 明确空态与多 session 同日时的交互细节
4. 开始实现“点击日历切换 session”功能

View File

@@ -0,0 +1,160 @@
# Hermes-first 重构计划索引
本目录用于沉淀 Jarvis 从“自研 agent 主流程 + Hermes 可选 adapter”转向 **Hermes-first 架构** 的分阶段计划。
目标不是把 Jarvis 砍掉重写,而是把架构中心调整为:
- **Hermes**:默认 execution core
- **Jarvis**product shell负责 chat UI、conversation/message 持久化、memory/knowledge/task、continuity、observability、rollback
---
## 当前目标
1. 不再把 Hermes 只看作可选 runtime而是作为默认核心方向。
2. 保留 Jarvis 的产品价值,不把业务层能力粗暴塞进 Hermes 黑盒。
3. 保证 chat 仍是连续会话体验,不接受每轮冷启动。
4. 保持现有 `/api/conversations/chat/stream` 与 SSE 契约稳定。
5. 保留迁移期 fallback / 回滚能力,不做不可逆替换。
---
## 文档说明
| 文件 | 说明 |
|------|------|
| `README.md` | 总览、阶段关系、总体原则 |
| `adr-hermes-first-architecture.md` | Hermes-first 的架构决策记录 |
| `phase-h0-ownership-and-adr.md` | ownership matrix、边界与成功标准 |
| `phase-h1-agent-service-inversion.md` | `AgentService` 从 runtime 本体转为产品层编排 |
| `phase-h2-continuity-envelope.md` | `Conversation.agent_state` 的 runtime-neutral envelope |
| `phase-h3-durable-session-lifecycle.md` | Hermes durable session lifecycle |
| `phase-h4-product-shell-assembly.md` | Jarvis product shell 的 pre-runtime assembly |
| `phase-h5-event-mapper-and-sse-contract.md` | Hermes event -> Jarvis SSE mapper |
| `phase-h6-frontend-hermes-first-session-model.md` | 前端从 runtime toggle 过渡到 Hermes-first session model |
| `phase-h7-default-rollout-and-fallback.md` | 默认切换、灰度、fallback 与回滚 |
| `checklist.md` | 分阶段执行清单 |
---
## 推荐阅读顺序
1. `adr-hermes-first-architecture.md`
2. `phase-h0-ownership-and-adr.md`
3. `phase-h1-agent-service-inversion.md`
4. `phase-h2-continuity-envelope.md`
5. `phase-h3-durable-session-lifecycle.md`
6. `phase-h4-product-shell-assembly.md`
7. `phase-h5-event-mapper-and-sse-contract.md`
8. `phase-h6-frontend-hermes-first-session-model.md`
9. `phase-h7-default-rollout-and-fallback.md`
10. `checklist.md`
---
## 当前总体状态2026-04-10
| Phase | 状态 | 说明 |
|------|------|------|
| H0 | 进行中 | 已明确从 adapter-first 转向 Hermes-first需要先补完整文档 |
| H1 | 待开始 | `AgentService` 仍过于集中Jarvis runtime 尚未完全 adapter 化 |
| H2 | 待开始 | `Conversation.agent_state` 尚未统一成 runtime-neutral envelope |
| H3 | 待开始 | `HermesSessionManager` 仍偏进程内原型 |
| H4 | 待开始 | Jarvis 的 memory/skills/task graph 仍需固化为 product shell 装配层 |
| H5 | 待开始 | SSE 兼容已初步存在,但缺少稳定事件映射边界 |
| H6 | 待开始 | 前端仍把 runtime 视作用户可切换字符串,而非 session model |
| H7 | 待开始 | 还没有服务端默认 runtime policy / rollout / fallback 策略 |
---
## 总体实施原则
1. **先文档后开发**:先写清楚阶段文档,再按文档开发。
2. **Hermes 做核心Jarvis 做产品**:不让 Jarvis 继续承担主 runtime 本体。
3. **连续对话优先**:必须支持 warm session / resumed session而不是每轮冷启动。
4. **契约稳定优先**:前端继续消费稳定 SSE不直接理解 Hermes 内部事件。
5. **渐进切换优先**:迁移期间保留 fallback 和回滚,不做一次性替换。
6. **复用优先**memory、skill shortlist、task graph、conversation persistence 尽量保留为 Jarvis 产品层能力。
---
## Ownership Matrix摘要
### Hermes Core
- session lifecycle
- runtime resume / recovery
- turn execution loop
- chunk streaming
- runtime-internal tool loop
### Jarvis Product Shell
- conversation/message persistence
- memory context assembly
- skill shortlist
- task graph
- product continuity
- SSE contract
- runtime observability
- rollout / fallback policy
### Shared Contracts
- runtime prepared context
- runtime event model
- continuity envelope
- health / metrics metadata
---
## 阶段依赖图
```text
H0 -> H1 -> H2 -> H3 -> H4 -> H5 -> H6 -> H7
```
说明:
- 没有 H1就无法真正把 Jarvis 从 runtime 本体降级为产品层。
- 没有 H2/H3就无法让 Hermes-first 具备可靠 continuity。
- 没有 H5/H6前端会被 Hermes 内部细节污染。
- 没有 H7就无法安全默认切换。
---
## 关键风险
1. 把 Hermes session id 错当成完整 continuity。
2. 让前端直接依赖 Hermes-native event 细节。
3. `AgentService` 持续膨胀成新的耦合中心。
4. runtime toggle 长期暴露为普通用户负担。
5. 只靠进程内 session manager缺少 durable 恢复。
6. 没有 rollback policy 就直接默认切换。
---
## 当前代码锚点
### Backend
- `backend/app/services/agent_service.py`
- `backend/app/services/agent_runtime/base.py`
- `backend/app/services/agent_runtime/hermes_runtime.py`
- `backend/app/services/agent_runtime/hermes_session_manager.py`
- `backend/app/models/conversation.py`
- `backend/app/schemas/conversation.py`
- `backend/app/routers/conversation.py`
### Frontend
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/pages/chat/index.vue`
- `frontend/src/stores/conversation.ts`
- `frontend/src/api/agent.ts`
---
## 预期阶段结论
当本轮文档与实施完成后,应该达到:
- Hermes 成为默认 execution core 的明确落地方向。
- Jarvis 保留为 product shell而不是继续扩展自研 runtime。
- chat 继续是消息流产品,不变成终端模拟器。
- 默认切换前拥有清晰的灰度、fallback、回滚策略。

View File

@@ -0,0 +1,84 @@
# ADRHermes-first 架构
## 状态
Accepted进入实施规划
## 背景
Jarvis 当前已经具备较强的自研 agent runtime 能力,但核心执行链路仍然偏自定义、偏集中式,导致:
- 执行 runtime 与产品层耦合过深
- Hermes 虽已接入真实 bridge但仍只是 adapter 分支
- 长驻 session、恢复、执行循环等能力没有形成更清晰的 runtime ownership
- 前端虽然能切 runtime但本质仍是 Jarvis-centered UX + backend branching
同时,用户明确表达:
- 更偏好 Hermes 的体系化能力
- 不希望继续扩展自研 agent 主链路
- 希望连续对话、常驻 session、不冷启动
- 要求先文档后开发
## 决策
采用 **Hermes-first architecture**
- Hermes 成为默认 execution core
- Jarvis 保留为 product shell
- 旧 Jarvis graph 在迁移期保留为 fallback / specialist path
- 前端继续使用 Jarvis chat product shell而不是直接暴露 Hermes 终端形态
- SSE 契约保持稳定,由 Jarvis 负责做 runtime event mapping
## 责任边界
### Hermes 负责
- session lifecycle
- runtime resume / restart / health
- execution loop
- chunk streaming
- runtime-internal tool orchestration
### Jarvis 负责
- conversation/message persistence
- memory / knowledge / retrospective assembly
- skill shortlist
- task graph shaping
- product continuity envelope
- SSE contract
- observability / metrics / attachments
- rollout / fallback / rollback policy
## 不采用的方案
### 方案 A继续保持 adapter-first
不采用原因:
- Hermes 长期只会是支线 runtime
- `AgentService` 会继续膨胀
- 无法真正把架构中心从 Jarvis runtime 本体迁走
### 方案 B直接删除 Jarvis自上而下改成 Hermes 原生产品
不采用原因:
- 会丢失 Jarvis 已有的 conversation、memory、task、业务层价值
- 缺乏迁移和回滚路径
- 风险过高
## 影响
正向影响:
- runtime 职责更清晰
- 长驻 session 方向更明确
- 后续维护重心从“造 runtime”转向“做产品能力”
负向影响:
- 迁移期需要同时维护 Hermes default path 与 Jarvis fallback path
- 需要重构 `AgentService` 和 continuity state
- 需要补 durable lifecycle 和 rollout policy
## 验收标准
1. Hermes 成为默认 execution core。
2. Jarvis 仍保留 product shell 能力。
3. 多轮对话 continuity 不依赖每轮冷启动。
4. SSE 前端契约保持稳定。
5. 默认切换有灰度与回滚路径。

View File

@@ -0,0 +1,65 @@
# Hermes-first 执行清单
## H0 Ownership / ADR
- [x] 新增 Hermes-first `README.md`
- [x] 新增 ADRHermes-first architecture
- [x] 明确 ownership matrix
- [x] 明确 Jarvis product shell 与 Hermes core 的边界
- [x] 明确成功标准与关键风险
## H1 AgentService 架构倒置
- [ ]`AgentService` 明确拆分为 assembly / dispatch / finalization
- [ ] 正式 adapter 化 Jarvis graph 路径
- [ ] 引入 runtime registry / factory
- [ ] 减少 `if runtime == ...` 的散落逻辑
- [ ] 保持 router / SSE 契约不破坏
## H2 Continuity Envelope
- [ ] 设计统一 `Conversation.agent_state` envelope
- [ ] 加入 `active_runtime`
- [ ] 加入 `runtime_state.hermes`
- [ ] 保留 Jarvis product continuity
- [ ] 增加 migration/version metadata
- [ ] 补充兼容旧状态读取策略
## H3 Durable Session Lifecycle
- [ ] 升级 `HermesSessionManager` 为 durable lifecycle manager
- [ ] 支持 warm / resumed / cold 状态
- [ ] 支持 hydrate / recreate / idle reclaim
- [ ] 增加 health / restart / stale session 处理
- [ ] 补充 session recovery 测试
## H4 Product Shell Assembly
- [ ] 固化 memory assembly
- [ ] 固化 skill shortlist assembly
- [ ] 固化 task graph assembly
- [ ] 把这些能力统一收敛到 prepared context
- [ ] 保证 Hermes 不直接吞掉产品层职责
## H5 Event Mapper / SSE
- [ ] 新增 Hermes event mapper 边界
- [ ] 保持 `metadata/progress/chunk/error/done`
- [ ] richer diagnostics 落到 observability / attachments
- [ ] 保证前端 parser 无需重写
## H6 Frontend Hermes-first Session Model
- [ ] 减少 `jarvis` 默认 runtime 假设
- [ ] 减少 Jarvis-specific runtime 文案耦合
- [ ] 提升 session/run metadata 为一等状态
- [ ] runtime toggle 收缩为灰度/调试能力
- [ ] 保持 chat 仍是消息流体验
## H7 Default Rollout / Fallback
- [ ] 引入默认 runtime policy
- [ ] 支持 cohort / feature flag rollout
- [ ] 保留 Jarvis graph fallback 路径
- [ ] 定义 rollback 条件与动作
- [ ] 用真实对话与指标验证默认切换时机

View File

@@ -0,0 +1,112 @@
# H-0 Hermes 现状探测与 PoC 边界
## 1. 目标
在不影响现有 Jarvis 主流程的前提下,确认 Hermes 是否适合作为 Jarvis chat 的可嵌入 runtime。
本阶段只回答 4 个问题:
1. Hermes 更适合如何接入Python API、单次 CLI、长驻 CLI、gateway还是其他形式
2. Hermes 是否支持 conversation 级别的长驻 session / resume
3. Hermes 是否能在后端被程序化调用,而不是只能人工交互?
4. Hermes 的接入是否能保持 Jarvis 现有 chat 页面协议稳定?
## 2. 当前已知信息
### 2.1 来自 Hermes 仓库的直接结论
- Hermes 主入口是 CLI`hermes`
- 提供 single query 模式:`-q` / `query`
- 提供 `resume` 机制
- 提供 gateway 模式
- README 明确说明:原生 Windows 不受支持,建议 WSL2
- `run_agent.py` 暴露了更直接的 Python 级 `chat(message, stream_callback=...)` 接口
- 内部有 SQLite session store说明其本身有 session persistence 概念
### 2.2 对 Jarvis 的意义
这说明 Hermes **不是只能人手操作的纯 TUI**,而是具备:
- 单次 query 入口
- session 恢复能力
- Python 层 chat 接口
- streaming callback 可能性
因此它存在被 Jarvis 后端托管成 runtime 的现实基础。
## 3. 本阶段输出
### 3.1 必须验证的能力
1. **安装方式**
- 是否能在当前环境隔离安装
- 是否需要迁移到 WSL2 才具备稳定运行条件
2. **非交互调用能力**
- 是否能用 CLI 单次 query 跑通
- 是否能用 Python 直接调用 `run_agent.py` 的 chat 接口
3. **session 能力**
- 是否能创建、恢复、复用 session
- 是否适合绑定 `conversation_id`
4. **输出接法**
- 是否能通过 callback / stdout 获取稳定文本流
- 是否可被映射成 Jarvis 现有 SSE 事件
### 3.2 不在本阶段做的事
- 不改现有 Jarvis 默认运行链路
- 不重写前端 chat 页面
- 不直接删除或停用 LangGraph 主流程
- 不引入一次性大迁移
## 4. 推荐 PoC 边界
### 4.1 推荐优先级
1. **优先验证 Python chat 接口**
- 理由:比解析 TUI 更稳
- 若可行,首版桥接应优先走这个路径
2. **其次验证 CLI 单次 query + resume**
- 作为备选方案
- 若 Python 接口不可控,可退而求其次
3. **最后才考虑 TUI/PTY 桥接**
- 成本高
- 不适合作为 Jarvis chat 的第一接法
### 4.2 PoC 成功标准
- 能在隔离环境中启动 Hermes
- 能程序化发送一条消息并得到结果
- 能确认 session 可复用或可 resume
- 能形成一个后端 runtime adapter 可实现的最小桥接思路
## 5. 可能结论及后续影响
### 结论 APython chat 接口稳定
- 最优方案
- H-1/H-2 直接围绕 Python adapter + session manager 展开
### 结论 BCLI `-q` + `resume` 稳定
- 可接受
- H-2 要更强调 session 句柄与进程生命周期管理
### 结论 C只能稳定跑 TUI
- 风险显著升高
- 需重新评估是否值得继续集成
### 结论 D当前环境无法稳定运行
- 可能需要 WSL2 或远程服务化托管
- 再决定是否继续推进
## 6. 验证清单
- [ ] 拉取 Hermes 仓库到隔离目录
- [ ] 明确 install 依赖与 Python 版本要求
- [ ] 确认单次 query 调用方式
- [ ] 确认 Python chat 接口是否可用
- [ ] 确认 session / resume 的可编程性
- [ ] 记录接入建议结论,作为 H-1 输入

View File

@@ -0,0 +1,90 @@
# H-1 Runtime Adapter 边界
## 1. 目标
在不改变现有 Jarvis 默认行为的前提下,先把 chat 主流程改造成**可切换 runtime** 的结构。
核心思想:
- router 不变
- SSE 契约尽量不变
- `AgentService` 内新增 runtime 分发边界
- Jarvis 先被包装成默认 runtime
- Hermes 作为显式实验 runtime 并存
## 2. 当前主链路
当前 chat 路径:
```text
frontend/useChatView.ts
-> frontend/api/conversation.ts
-> POST /api/conversations/chat/stream
-> backend/app/routers/conversation.py
-> backend/app/services/agent_service.py
-> backend/app/agents/graph.py
```
问题在于:
- `AgentService` 直接耦合 Jarvis 图运行时
- 没有 runtime selector
- Hermes 无法以低风险方式并入
## 3. 本阶段目标结构
```text
conversation router
-> AgentService
-> resolve runtime
-> JarvisRuntimeAdapter | HermesRuntimeAdapter
```
### 3.1 关键要求
1. Jarvis 仍为默认 runtime
2. 不改现有 URL 和 SSE event name
3. 前端只需要传一个可选 `runtime` 字段
4. backend 可以继续把 Hermes 视为“可插拔执行器”
## 4. 数据契约
建议在 chat request 中增加:
- `runtime: "jarvis" | "hermes" | null`
规则:
- `null` / 未传:默认 `jarvis`
- `jarvis`:保持现有行为
- `hermes`:转入 Hermes adapter
## 5. 推荐文件调整
### Backend
- `backend/app/schemas/conversation.py`
- 增加 runtime 字段
- `backend/app/services/agent_service.py`
- 增加 runtime 解析
- 增加 runtime dispatch
- 新目录:`backend/app/services/agent_runtime/`
- `base.py`
- `jarvis_runtime.py`
- `hermes_runtime.py`
### Frontend
- `frontend/src/api/conversation.ts`
- 请求体增加 runtime
- `frontend/src/pages/chat/composables/useChatView.ts`
- 增加 selectedRuntime 状态
## 6. 约束
- 本阶段不要求 Hermes 已经完整可运行
- 允许先落 Hermes adapter 骨架
- 但不允许破坏 Jarvis 现有路径
## 7. 完成标准
- [ ] `runtime` 字段进入 request schema
- [ ] backend 已有 runtime dispatch 入口
- [ ] Jarvis 仍能正常完成原有 chat / chat_stream
- [ ] Hermes 可以作为占位 runtime 被请求到
- [ ] SSE 事件协议未被破坏

View File

@@ -0,0 +1,107 @@
# H-2 长驻 Hermes Session Manager
## 1. 目标
让 Hermes 以 conversation 级别的长驻 session 运行,而不是每条消息都重新冷启动。
这是本次接入最关键的用户体验目标:
- 连续上下文
- 无缝多轮对话
- 降低重复初始化耗时
- 避免“每次都像重新开机”
## 2. 会话归属原则
Hermes session 以 `conversation_id` 作为主键绑定。
原因:
1. Jarvis 现有 chat 的持久化中心本来就是 conversation
2. 前后端现有逻辑都已围绕 conversation 组织
3. conversation 是最自然的“连续对话上下文容器”
必要时可组合:
- `user_id + conversation_id`
## 3. 会话管理职责
建议新增 `HermesSessionManager`,负责:
1. 根据 conversation 获取或创建 Hermes session
2. 保存内存态句柄
3. 记录 last_used 时间
4. 做每会话锁,防止并发 turn 污染
5. 做 idle timeout 回收
6. 在异常时受控重建 session
## 4. 与持久化层的关系
### 4.1 内存态
内存里保存:
- session handle
- lock
- last_used
- health status
- restart count
### 4.2 数据库存储
建议把 Hermes runtime 元数据落入 `Conversation.agent_state`,但不要覆盖现有 Jarvis continuity。
建议结构:
```json
{
"runtime": "jarvis | hermes",
"runtime_state": {
"jarvis": { ... },
"hermes": {
"session_id": "...",
"last_used_at": "...",
"restart_count": 0,
"status": "healthy"
}
}
}
```
这样支持:
- 并存
- 切换
- 回滚
- 不破坏旧 continuity 数据
## 5. 生命周期建议
```text
用户发起消息
-> 根据 conversation 找 session
-> 有则复用
-> 无则创建
-> 执行消息
-> 更新 last_used / 状态
-> 空闲超时后回收
```
### 5.1 回收策略
- conversation 长时间无活动后可回收
- 但回收前要把必要 runtime 元数据保存到 `agent_state`
### 5.2 异常策略
- 首次异常:尝试一次受控重建
- 重建失败:返回 clean error
- 不能因此破坏 Jarvis 默认路径
## 6. 关键设计约束
1. 一个 conversation 同一时刻只能有一个进行中的 Hermes turn
2. 不允许两个并发消息写进同一个 Hermes session
3. session manager 不能成为 Jarvis 主流程的单点故障
4. Hermes 失败时,不能污染 conversation 的历史结构
## 7. 完成标准
- [ ] `conversation_id` 能稳定映射到 Hermes session
- [ ] session 可复用,不是每轮冷启动
- [ ] 有 per-conversation lock
- [ ] 有 idle timeout / cleanup 机制
- [ ] 有 crash / recreate 基础机制
- [ ] metadata 可写入 `Conversation.agent_state`

View File

@@ -0,0 +1,102 @@
# H-3 Hermes Adapter 与上下文复用
## 1. 目标
Hermes 只作为新的执行 runtime 接进来,不重新发明一套 Jarvis memory / context / chat protocol。
也就是说:
- Jarvis 已有的上下文构建能力继续复用
- Hermes 输出被适配为现有 chat 消息流
- 前端尽量不理解 Hermes 内部细节
## 2. 可复用能力
### 2.1 Memory
- `backend/app/services/memory_service.py`
继续复用:
- conversation summary
- recalled memory
- user memory
- knowledge brain 注入
### 2.2 Skill shortlist
- `backend/app/agents/skills/retriever.py`
继续复用:
- request 相关 skill shortlist
### 2.3 Task graph
- `backend/app/agents/orchestration/task_graph.py`
继续复用:
- bounded task graph
- parallel worthiness 等前置分析
## 3. 推荐数据流
```text
AgentService
-> 读取 conversation / message / files
-> 构建 memory context
-> 构建 skill shortlist
-> 构建 task graph / runtime request context
-> 根据 runtime 分发
-> JarvisRuntimeAdapter
-> HermesRuntimeAdapter
```
这样 Hermes 看到的是**已整理好的 runtime context**,而不是被迫直接复用 Jarvis 图内部状态机。
## 4. SSE 契约保持不变
继续沿用现有事件:
- `metadata`
- `progress`
- `chunk`
- `error`
- `done`
### 4.1 原因
前端现有:
- `conversationApi.chatStream()` 已解析这套事件
- `useChatView.ts` 已依赖这套事件更新 thinking state / orchestration panel
如果这里大改,会让前端接入成本飙升。
### 4.2 Hermes event mapping
Hermes 内部即使没有完全等价事件,也应该适配成:
- 初始化 / session 准备 -> `progress`
- 实际文本输出 -> `chunk`
- 错误 -> `error`
- 完成 -> `done`
缺字段可以降级,但 event 名称不要改。
## 5. 持久化与可观测性
继续沿用:
- `Message` 表保存 user / assistant 内容
- `Conversation.agent_state` 保存 runtime continuity 元数据
- `attachments` 可用于记录 Hermes 运行附加信息
建议:
- 把 Hermes 观测信息放在 runtime-tagged attachment 中
- 不把探测日志直接渲染进用户可见消息正文
## 6. 边界约束
1. Hermes continuity 与 Jarvis continuity 分开存
2. 不要让 Hermes adapter 直接改写现有 Jarvis graph 状态格式
3. 前端不直接显示“终端字节流”
4. Hermes 适配失败时,必须 clean fail
## 7. 完成标准
- [ ] 现有 memory pipeline 可被 Hermes 复用
- [ ] 现有 skill shortlist / task graph 可被 Hermes 复用
- [ ] Hermes 输出成功映射到既有 SSE 契约
- [ ] assistant message 按现有结构持久化
- [ ] Hermes continuity 数据不覆盖 Jarvis continuity 数据

View File

@@ -0,0 +1,85 @@
# H-4 前端切换与并行评估
## 1. 目标
让 chat 页面在尽量不改变现有体验的前提下,支持切换 `jarvis | hermes`,并进入受控评估期。
重点不是做新 UI而是
- 能切换 runtime
- 能继续对话
- 能收集真实效果
- 不影响现有默认使用路径
## 2. 前端最小改动原则
### 2.1 继续复用现有页面
主要锚点:
- `frontend/src/pages/chat/composables/useChatView.ts`
- `frontend/src/api/conversation.ts`
- `frontend/src/pages/chat/index.vue`
### 2.2 最小改动内容
1. 增加 `selectedRuntime`
2. 在发送消息时把 runtime 放入 request body
3. 页面可加一个轻量 toggle / selector
4. 不改变现有消息渲染逻辑
5. 不把页面改造成“网页终端”
## 3. 评估期策略
### 3.1 默认值
- Jarvis 仍为默认 runtime
- Hermes 为显式选择项
### 3.2 评估维度
必须记录:
- 首 token 延迟
- 完整回复耗时
- 第二轮/第三轮连续对话体验
- session 是否稳定复用
- 工具调用效果
- memory 是否有效承接
- 异常率 / 重启率
- 开发维护复杂度
### 3.3 用户体验标准
如果 Hermes 要成为默认 runtime至少应满足
1. 不比 Jarvis 更割裂
2. 不出现频繁 session 丢失
3. 前端不需要额外理解复杂运行细节
4. 整体体验更像连续助手而不是一次性问答器
## 4. 验收建议
### Frontend
- [ ] Jarvis 默认聊天体验不变
- [ ] 可切换到 Hermes 并成功发消息
- [ ] 历史会话读取不崩
- [ ] orchestration panel 不因 Hermes 字段较少而崩溃
### Backend
- [ ] Hermes 路径不影响 Jarvis 默认路径
- [ ] SSE 解析不需要重写
- [ ] conversation/message 结构保持兼容
### Product
- [ ] 可以真实比较两个 runtime
- [ ] 结论可支持“继续替换”或“放弃替换”
## 5. 阶段结论输出
本阶段结束后,应明确给出以下结论之一:
### 结论 AHermes 明显更优
- 新开一轮“默认切换 / 逐步替换”规划
### 结论 BHermes 可保留为实验 runtime
- 不切默认
- 继续特定场景使用
### 结论 CHermes 不适合当前 Jarvis
- 中止替换计划
- 保留本轮探索结论供后续参考

Some files were not shown because too many files have changed in this diff Show More