refactor: 梳理规则体系并统一记忆与部署流程

This commit is contained in:
qzl
2026-03-23 17:57:24 +08:00
parent 2a14ad1d8e
commit f4b7eb7e09
39 changed files with 2091 additions and 1454 deletions
+47 -280
View File
@@ -1,302 +1,69 @@
# Backend Development Rules
# Backend Domain Rules
This document defines Python/FastAPI backend development constraints.
This file governs `backend/**` only. Keep it minimal, enforceable, and non-duplicative.
## Scope and Precedence
## Scope & Precedence
- This file applies to all changes under `backend/**`.
- It extends root routing rules in `AGENTS.md` and workspace global runtime rules.
- If rules conflict, follow stricter requirements.
- Keep backend-only rules here; do not duplicate them in root `AGENTS.md`.
- Inherits root `AGENTS.md` and workspace runtime rules.
- If rules conflict, apply the stricter one.
- Keep backend-only constraints here; do not duplicate root routing logic.
## Python Environment
## Runtime & Commands
**MUST use uv for dependency management and virtual environment execution.**
- Python commands must use `uv` (`uv run`, `uv add`).
- Backend startup/shutdown must use `./infra/scripts/app.sh`.
- Check runtime logs from `./logs/*.log`.
- All Python commands: `uv run <command>`
- Add dependencies: `uv add <package>`
- All dependencies declared in `pyproject.toml`
## Code Quality Baseline
## Code Quality Checks
- Do not bypass lint/type gates (`ruff`, `basedpyright`).
- Use project logging (`core.logging`), never `print()` in runtime code.
- HTTP errors must follow RFC 7807 (`application/problem+json`).
**Git pre-commit hook enforces code quality before commit.**
## Configuration & Secrets
Pre-commit hook automatically runs on backend/ directory:
- `ruff check` - code style and linting
- `basedpyright` - type checking with error level
- Read env only through `core.config.settings` (`Settings` / `config`).
- Do not use `os.getenv`/manual env parsing in backend runtime.
- Never hardcode keys/tokens/passwords.
If any error detected, commit is rejected. Fix errors before committing.
Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues.
## Architecture Rules
## Logging
- Use `schema -> repository -> service` layering.
- Repository: CRUD + query composition only; no auth decisions, no transaction boundary.
- Service: authz + business logic + transaction boundary.
- `owner_id` must come from verified JWT (`sub`), never from client payload.
**MUST use project logger for all runtime logging.**
## Schema & Contract Rules
- Use project logger from `backend/src/core/logging/*`
- Prohibit: print(), logging.info/warning/error directly
- Required: structured logging with context
- Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
- Schema-first for new/changed data contracts.
- Strong typing required at boundaries (Pydantic/dataclass); avoid weak untyped payload contracts.
- Protocol/data contract changes must stay aligned with `docs/protocols/`.
## HTTP API Standards
## Database Rules
**MUST follow RESTful conventions and RFC 7807 for error responses.**
- Supabase Auth is identity source; backend enforces business authorization.
- Use service-role DB access only in backend.
- Soft delete uses `deleted_at`; reads must exclude deleted records by default.
- Alembic is the only schema migration source of truth.
- Errors must use `application/problem+json` with RFC 7807 fields
- No custom response envelopes for HTTP APIs
- Request and response validation must use Pydantic models
## Agent Runtime & Tools
## Environment Variables
- AG-UI protocol is mandatory for agent loop behavior.
- `ToolAgentOutput.result` is the canonical tool result field.
- Tool results must be machine-oriented and include IDs/outcomes needed for chaining.
**Backend env access MUST go through** `backend/src/core/config/settings.py`.
## Tool Schema Rules for Small Models (e.g., qwen3.5-flash)
- Only use `Settings()` / `config` from `core.config.settings`
- Do not call `os.environ`, `os.getenv`, `dotenv`, or manual parsing in backend runtime code
- Tests can set env vars via `monkeypatch.setenv`, and should read values via `Settings()` unless the test is explicitly validating env plumbing
- Canonical principle: one source of truth per setting; no duplicate/derived env vars in backend code
## TDD Workflow
### Coverage Requirements
- Minimum coverage: 80%
- Required test types:
- Unit: isolated functions, utilities, components
- Integration: API endpoints, database operations
- E2E: critical user flows (Playwright)
### Limited Exceptions
- Docs-only changes (README, comments, formatting) may skip integration/E2E
- Non-runtime config changes may skip E2E if no behavior changes
- Any runtime code change requires unit + integration + E2E
- If an exception is used, record the reason in the PR/test notes
### Mandatory TDD Workflow
1. Write tests (RED) - they must fail
2. Run tests - confirm failure
3. Implement minimal code (GREEN) - only to pass
4. Run tests - confirm success
5. Refactor (IMPROVE)
6. Verify coverage - target 80%+
### Enforcement
- Must use the `tdd-guide` agent for new features
- Do not write implementation before tests
- Do not lower coverage requirements
- Must include unit, integration, and E2E tests
## Code Style
### Immutability
**ALWAYS create new objects, NEVER mutate.**
```python
# WRONG: Mutation
def update_user(user, name):
user["name"] = name
return user
# CORRECT: Immutability
def update_user(user, name):
return {**user, "name": name}
```
### File Organization
- Many small files over few large files
- 200-400 lines typical, 800 max per file
- Extract utilities from large components
### Error Handling
Always handle errors comprehensively:
```python
try:
result = risky_operation()
return result
except Exception as exc:
logger.exception("Operation failed")
raise RuntimeError("Detailed user-friendly message") from exc
```
## Security
### Mandatory Security Checks
Before ANY commit:
- [ ] No hardcoded secrets (API keys, passwords, tokens)
- [ ] All user inputs validated (use Pydantic)
- [ ] SQL injection prevention (parameterized queries)
- [ ] Authentication/authorization verified
### Secret Management
```python
# NEVER: Hardcoded secrets
api_key = "sk-proj-xxxxx"
# ALWAYS: Read through centralized settings
from core.config.settings import Settings
settings = Settings()
api_key = settings.openai_api_key
if not api_key:
raise ValueError("OPENAI_API_KEY not configured in settings")
```
## Database Development Rules
### Architecture
- **Supabase**: authentication (JWT source of truth)
- **Backend**: business authorization (service layer)
- **SQLAlchemy ORM**: data access layer (async + asyncpg, service_role connection)
### Code Organization
Use `schemas / repository / service` pattern:
- `schemas.py` — Pydantic models
- `repository.py` — CRUD only, no auth, no commit (only flush), must receive session (never create session/engine)
- `service.py` — authorization + business logic + transaction boundary (must commit/rollback)
- `dependencies.py` — DI (`get_db`, `get_current_user`)
### Schema-First and Strong Typing (Mandatory)
**Data model constraints are the first priority. Define schemas before implementation.**
- Any backend feature that introduces or changes data structures MUST define/update strong-typed schemas first.
- All request/response/domain/runtime contracts MUST use explicit Pydantic models or typed dataclasses.
- Prohibit weak typing in data contracts: `Any`, untyped `dict`, untyped `list`, `object` placeholders.
- Prohibit using raw `dict[str, object]` as the canonical contract for pipeline/stage/config/domain payloads.
- External library boundaries may accept weakly typed input only at adapter edges; data MUST be converted immediately into local strong-typed schemas before entering service/domain layers.
- New model placement rules:
- Cross-module runtime/domain contracts: `backend/src/schemas/**`
- HTTP request/response contracts: `backend/src/v1/**/schemas.py`
- ORM persistence models: `backend/src/models/**`
### Auth & Data Access
- Backend must verify JWT signature and expiration (not just decode)
- Extract `user_id` from JWT `sub` claim
- Backend connects with **service_role** (bypasses RLS)
- `owner_id` always derived from JWT, never from client
- Scope queries by owner/org; public access must be explicit
- service_role key is backend-only; never expose credentials
- Prohibit calling Supabase Admin API (service_role key) from repository/service layers
### Soft Delete
**Soft delete marks data as invisible, not cascade delete.**
- Use `deleted_at: datetime | None` column (via `SoftDeleteMixin`)
- **Query filtering**: Repository `_apply_soft_delete_filter()` auto-excludes deleted records
- **No automatic cascade**: Related data stays intact; visibility controlled by JOIN filtering
- **Cascade only for strong dependencies**: When parent deletion must invalidate children, implement in Service layer explicitly
- **Recovery**: Only restore the record itself; related data visibility restored automatically via queries
- **Unique constraints**: Use partial indexes excluding `deleted_at IS NOT NULL` to allow re-creation
```python
# Partial unique index in migration
op.execute("""
CREATE UNIQUE INDEX ux_user_email
ON users(email)
WHERE deleted_at IS NULL
""")
```
### Migrations
- **Alembic is the single source of truth** for schema migrations
- ORM model changes → `alembic revision --autogenerate`
- Raw SQL (policies, triggers, functions) → `op.execute()`
- Migrations must be reversible; no reliance on generated IDs
### Enum Storage Convention
**Store enum names (strings), not integer values.**
- Use `VARCHAR(20)` + `CHECK` constraint in database
- Use Python `Enum` class with `str` base in code
```python
class AgentType(str, Enum):
INTENT_RECOGNITION = "INTENT_RECOGNITION"
TASK_EXECUTION = "TASK_EXECUTION"
RESULT_REPORTING = "RESULT_REPORTING"
```
### RLS Policy
- Backend does not rely on RLS for correctness (uses service_role), but RLS is mandatory as a defensive boundary for tables in PostgREST-exposed schemas.
- **Mandatory default**: any new business table in `public` must enable RLS in the same Alembic migration.
- The same migration must create policies covering `SELECT/INSERT/UPDATE/DELETE` (minimum requirement).
- Recommended default policy set for `anon, authenticated`: deny all operations first, then open explicit access only when required.
- `alembic_version` must not be exposed to `anon` or `authenticated`.
#### Exemption Rule (strict)
- Exemptions are allowed only when a new `public` table is guaranteed not to be exposed to PostgREST clients.
- Exemptions must be explicit in the migration file with rationale and verification notes.
- If exposure is uncertain, do not exempt: enable defensive RLS by default.
#### Migration Checklist
- [ ] New `public` business table has `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` in migration
- [ ] Policies for `SELECT/INSERT/UPDATE/DELETE` are present in migration
- [ ] Policy target roles are explicit (`anon`, `authenticated`, or both)
- [ ] Downgrade path is reversible and does not silently weaken intended production security
- [ ] Any exemption is documented with clear non-exposure evidence
## Backend Startup
**Always use `./infra/scripts/app.sh` to start/stop the backend.** Do not start uvicorn directly.
**Always use `./logs/*.log` to check the backend log output.**
## Agent Loop (AG-UI Protocol)
Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance.
## Custom Tool Result Contract
Custom tool `ToolAgentOutput` MUST follow these rules:
- Use field name `result` only. Do not introduce or keep `result_summary` compatibility aliases.
- `metadata.tool_agent_output` is the canonical source for runtime observation and history replay.
- `tool_call_args` stores input snapshot only; avoid mixing execution output into `tool_call_args`.
- `result` stores output facts only; do not repeat input parameters already present in `tool_call_args`.
- `result` is for downstream agent reasoning and tool chaining, not for end-user presentation.
- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers and scheduling-critical fields.
- For write tools, include per-item operation outcomes and affected resource identifiers in `result`.
- Keep `result` concise, deterministic, and machine-oriented; avoid decorative wording and UI-style formatting.
## Multi-Agent Orchestration (AgentScope Framework)
Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance.
### Core Principles
- Use AgentScope for orchestrating multiple agents working together
- Define clear agent roles, stage responsibilities, and pipeline boundaries
- Leverage AgentScope built-in workflow and tool middleware mechanisms
- Follow AgentScope best practices for agent configuration
### Key Components
- **Agents**: Autonomous units with specific roles and goals
- **Tasks**: Stage-specific prompts and execution goals
- **Pipelines**: Ordered orchestration flow between agents
- **Tools**: Capabilities available to agents
- **Flows**: Workflow orchestration and state management
- Prefer `operations: list[OperationModel]` over parallel arrays.
- Validate tool args with strict Pydantic models (`extra="forbid"`).
- Keep payloads JSON-native (objects/lists), shallow, and deterministic.
- Make action-specific required fields explicit and fail with structured errors.
- Return per-item outcomes (`success/failed`, identifiers, partial status) for self-correction.
- Avoid broad entry-point coercion fallbacks; fix schema/prompt alignment first.
- Do not pass provider request fields with `None` values (avoid upstream 400 blocking tool calls).
## Testing
### Real Database Tests
Tests requiring real Supabase operations MUST use environment variables:
- Define `TestSettings` in `settings.py` with nested configuration
- Access via `settings.test.email` / `settings.test.password`
- NEVER hardcode credentials in code
- Follow TDD for feature/bugfix work when practical.
- Prioritize regression tests for changed logic/contracts.
- Real DB tests must use `settings.test.*`; never hardcode test credentials.
+98 -1
View File
@@ -9,7 +9,7 @@ from core.logging import get_logger
from models.agent_chat_message import AgentChatMessageRole
from models.agent_chat_session import AgentChatSessionStatus
from schemas.agent.system_agent import AgentType
from schemas.agent.runtime_models import AgentOutput, ToolAgentOutput
from schemas.agent.runtime_models import AgentOutput, RouterAgentOutput, ToolAgentOutput
from schemas.agent.visibility import SystemVisibilityBit, bit_mask
from schemas.messages.chat_message import AgentChatMessageMetadata
@@ -79,6 +79,14 @@ class SqlAlchemyEventStore:
session_repo=session_repo,
message_repo=message_repo,
)
elif event_type == "STEP_FINISHED":
await self._persist_router_step_output(
event=event,
session_id=session_id,
chat_session=chat_session,
session_repo=session_repo,
message_repo=message_repo,
)
elif event_type == "TOOL_CALL_RESULT":
await self._persist_tool_call_result(
event=event,
@@ -199,6 +207,95 @@ class SqlAlchemyEventStore:
cost_delta=cost,
)
async def _persist_router_step_output(
self,
*,
event: dict[str, Any],
session_id: UUID,
chat_session: Any,
session_repo: SessionRepository,
message_repo: MessageRepository,
) -> None:
step_name = self._event_value(event, "stepName")
if not isinstance(step_name, str) or step_name.strip().lower() != "router":
return
run_id = self._event_value(event, "runId")
run_id_value = run_id if isinstance(run_id, str) and run_id else None
if run_id_value is None:
return
persist_payload = event.get("_router_persist")
if not isinstance(persist_payload, dict):
return
router_output_raw = persist_payload.get("router_output")
response_metadata_raw = persist_payload.get("response_metadata")
if not isinstance(router_output_raw, dict):
return
response_metadata = (
response_metadata_raw if isinstance(response_metadata_raw, dict) else {}
)
model_code_raw = response_metadata.get("model")
model_code = model_code_raw if isinstance(model_code_raw, str) else None
input_tokens = self._to_int(response_metadata.get("inputTokens"))
output_tokens = self._to_int(response_metadata.get("outputTokens"))
token_delta = input_tokens + output_tokens
cost = self._to_decimal(response_metadata.get("cost"))
latency_ms = self._to_int_or_none(response_metadata.get("latencyMs"))
try:
router_output = RouterAgentOutput.model_validate(router_output_raw)
metadata_model = AgentChatMessageMetadata(
run_id=run_id_value,
agent_type=AgentType.ROUTER,
router_agent_output=router_output,
)
except Exception:
self._logger.warning(
"invalid router metadata payload",
run_id=run_id_value,
)
return
content = ""
locked_session = await session_repo.lock_session_for_update(
session_id=session_id
)
if locked_session is None:
return
seq = int(getattr(locked_session, "message_count", 0) or 0) + 1
await message_repo.append_message(
session_id=session_id,
seq=seq,
role=AgentChatMessageRole.ASSISTANT,
content=content,
model_code=model_code,
metadata=metadata_model.model_dump(mode="json", exclude_none=True),
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=cost,
latency_ms=latency_ms,
visibility_mask=0,
)
current_status = getattr(chat_session, "status", AgentChatSessionStatus.RUNNING)
status = (
current_status
if isinstance(current_status, AgentChatSessionStatus)
else AgentChatSessionStatus.RUNNING
)
await self._update_session_state(
session_repo=session_repo,
chat_session=chat_session,
status=status,
message_delta=1,
token_delta=token_delta,
cost_delta=cost,
)
async def _persist_tool_call_result(
self,
*,
+28 -10
View File
@@ -16,7 +16,7 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.agentscope.runtime.json_react_agent import JsonReActAgent
from core.agentscope.runtime.model_tracking import TrackingChatModel
from core.agentscope.runtime.stage_emitter import PipelineStageEmitter
from core.agentscope.tools.tool_config import AgentTool
from core.agentscope.tools.tool_config import AgentTool, resolve_tool_function_names
from core.agentscope.tools.toolkit import build_toolkit
from core.agentscope.utils import (
finalize_json_response,
@@ -123,7 +123,11 @@ class AgentScopeRunner:
owner_id: UUID,
enabled_tools: list[AgentTool],
) -> Any:
tool_names = [t.value for t in enabled_tools] if enabled_tools else []
tool_names = (
sorted(resolve_tool_function_names(set(enabled_tools)))
if enabled_tools
else []
)
return build_toolkit(
session=session,
owner_id=owner_id,
@@ -189,6 +193,14 @@ class AgentScopeRunner:
run_input=run_input,
step_name=AgentType.ROUTER.value,
event_type="STEP_FINISHED",
extra_event={
"_router_persist": {
"router_output": router_output.model_dump(
mode="json", exclude_none=True
),
"response_metadata": router_result.response_metadata,
}
},
)
return router_output
@@ -382,11 +394,13 @@ class AgentScopeRunner:
self, *, stage_config: SystemAgentRuntimeConfig
) -> TrackingChatModel:
generate_kwargs: dict[str, Any] = {
"temperature": stage_config.llm_config.temperature,
"max_tokens": stage_config.llm_config.max_tokens,
"timeout": stage_config.llm_config.timeout_seconds,
"extra_body": {"enable_thinking": False},
}
if stage_config.llm_config.temperature is not None:
generate_kwargs["temperature"] = stage_config.llm_config.temperature
if stage_config.llm_config.max_tokens is not None:
generate_kwargs["max_tokens"] = stage_config.llm_config.max_tokens
model = OpenAIChatModel(
model_name=stage_config.model_code,
@@ -423,15 +437,19 @@ class AgentScopeRunner:
run_input: RunAgentInput,
step_name: str,
event_type: str,
extra_event: dict[str, Any] | None = None,
) -> None:
payload: dict[str, Any] = {
"type": event_type,
"threadId": run_input.thread_id,
"runId": run_input.run_id,
"stepName": step_name,
}
if extra_event:
payload.update(extra_event)
await pipeline.emit(
session_id=run_input.thread_id,
event={
"type": event_type,
"threadId": run_input.thread_id,
"runId": run_input.run_id,
"stepName": step_name,
},
event=payload,
)
def _resolve_runtime_client_time(
@@ -52,6 +52,50 @@ class CalendarShareInvitee(BaseModel):
)
class CalendarWriteOperation(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["create", "update", "delete"] = Field(
description="Action type for this operation item."
)
event_id: str | None = Field(
default=None,
description="Event id required for update/delete.",
)
title: str | None = Field(default=None, description="Event title.")
description: str | None = Field(default=None, description="Event description.")
start_at: str | None = Field(
default=None,
description="Start time in ISO 8601 with timezone offset.",
)
end_at: str | None = Field(
default=None,
description="End time in ISO 8601 with timezone offset.",
)
event_timezone: str | None = Field(
default=None,
description="IANA timezone for the event.",
)
location: str | None = Field(default=None, description="Event location.")
color: str | None = Field(default=None, description="Event color.")
reminder_minutes: int | None = Field(
default=None,
ge=0,
le=10080,
description="Reminder minutes before event start.",
)
status: Literal["active", "completed", "canceled", "archived"] | None = Field(
default=None,
description="Optional status for update action.",
)
class CalendarWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20)
def _validate_runtime_context(
*,
tool_name: str,
@@ -178,125 +222,48 @@ async def calendar_read(
async def calendar_write(
operations: Annotated[
list[Literal["create", "update", "delete"]],
list[CalendarWriteOperation],
Field(
description=(
"Batch operations list. Each item must be create, update, or delete."
"Batch operation objects. Each item includes action and its fields. "
"Use create/update/delete in a single call."
),
min_length=1,
max_length=20,
),
],
event_ids: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event id list aligned with operations. "
"Required for update/delete item."
)
),
] = None,
titles: Annotated[
list[str | None] | None,
Field(description="Optional title list aligned with operations."),
] = None,
descriptions: Annotated[
list[str | None] | None,
Field(description="Optional description list aligned with operations."),
] = None,
start_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional start time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
end_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional end time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
event_timezones: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event timezone list aligned with operations, IANA timezone."
)
),
] = None,
locations: Annotated[
list[str | None] | None,
Field(description="Optional location list aligned with operations."),
] = None,
colors: Annotated[
list[str | None] | None,
Field(description="Optional color list aligned with operations."),
] = None,
reminder_minutes_list: Annotated[
list[int | None] | None,
Field(
description=(
"Optional reminder minutes list aligned with operations, value range 0-10080."
)
),
] = None,
statuses: Annotated[
list[Literal["active", "completed", "canceled", "archived"] | None] | None,
Field(description="Optional status list aligned with operations."),
] = None,
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Batch create/update/delete calendar events using aligned list parameters.
"""Batch create/update/delete calendar events using operation objects.
Args:
operations: Operation list. Length defines batch size.
event_ids: Optional event id list aligned with operations.
titles: Optional title list aligned with operations.
descriptions: Optional description list aligned with operations.
start_ats: Optional start time list aligned with operations.
end_ats: Optional end time list aligned with operations.
event_timezones: Optional event timezone list aligned with operations.
locations: Optional location list aligned with operations.
colors: Optional color list aligned with operations.
reminder_minutes_list: Optional reminder minute list aligned with operations.
statuses: Optional status list aligned with operations.
Constraints:
- All provided list parameters must have the same length as operations.
- create item requires start_ats[i] and event_timezones[i].
- update/delete item requires event_ids[i].
- start/end datetime must include timezone offset.
operations: Batch operation objects.
- create requires start_at and event_timezone.
- update/delete requires event_id.
- datetime fields must include timezone offset.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_write"
try:
parsed_batch = CalendarWriteBatchArgs.model_validate({"operations": operations})
except Exception as exc: # noqa: BLE001
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args={"operations": operations},
code=code,
message=message,
retryable=retryable,
)
def _align_list(name: str, values: list[Any] | None, size: int) -> list[Any | None]:
if values is None:
return [None] * size
if len(values) != size:
raise ValueError(f"{name} 长度必须与 operations 一致")
return list(values)
batch_size = len(operations)
tool_call_args = {
"operations": operations,
"event_ids": event_ids,
"titles": titles,
"descriptions": descriptions,
"start_ats": start_ats,
"end_ats": end_ats,
"event_timezones": event_timezones,
"locations": locations,
"colors": colors,
"reminder_minutes_list": reminder_minutes_list,
"statuses": statuses,
"operations": [
operation.model_dump(mode="json", exclude_none=True)
for operation in parsed_batch.operations
]
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
@@ -311,40 +278,26 @@ async def calendar_write(
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
aligned_event_ids = _align_list("event_ids", event_ids, batch_size)
aligned_titles = _align_list("titles", titles, batch_size)
aligned_descriptions = _align_list("descriptions", descriptions, batch_size)
aligned_start_ats = _align_list("start_ats", start_ats, batch_size)
aligned_end_ats = _align_list("end_ats", end_ats, batch_size)
aligned_event_timezones = _align_list(
"event_timezones", event_timezones, batch_size
)
aligned_locations = _align_list("locations", locations, batch_size)
aligned_colors = _align_list("colors", colors, batch_size)
aligned_reminders = _align_list(
"reminder_minutes_list", reminder_minutes_list, batch_size
)
aligned_statuses = _align_list("statuses", statuses, batch_size)
success_count = 0
failed_count = 0
success_event_ids: list[str] = []
result_items: list[dict[str, Any]] = []
for idx, operation in enumerate(operations):
event_id = aligned_event_ids[idx]
title = aligned_titles[idx]
description = aligned_descriptions[idx]
start_at = aligned_start_ats[idx]
end_at = aligned_end_ats[idx]
event_timezone = aligned_event_timezones[idx]
location = aligned_locations[idx]
color = aligned_colors[idx]
reminder_minutes = aligned_reminders[idx]
status = aligned_statuses[idx]
for operation in parsed_batch.operations:
event_id = operation.event_id
title = operation.title
description = operation.description
start_at = operation.start_at
end_at = operation.end_at
event_timezone = operation.event_timezone
location = operation.location
color = operation.color
reminder_minutes = operation.reminder_minutes
status = operation.status
try:
if operation == "create":
if operation.action == "create":
if start_at is None or not start_at.strip():
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
@@ -385,7 +338,7 @@ async def calendar_write(
success_event_ids.append(str(created.id))
continue
if operation == "update":
if operation.action == "update":
if event_id is None or not event_id.strip():
raise ValueError("更新日程需要提供 event_id")
parsed_event_id = UUID(event_id)
@@ -429,7 +382,7 @@ async def calendar_write(
success_event_ids.append(str(updated.id))
continue
if operation == "delete":
if operation.action == "delete":
if event_id is None or not event_id.strip():
raise ValueError("删除日程需要提供 event_id")
await service.delete(UUID(event_id))
+274 -100
View File
@@ -16,7 +16,7 @@ from core.agentscope.tools.utils.tool_response_builder import (
build_tool_response,
)
from models.memories import MemoryType
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
@@ -38,6 +38,12 @@ class MemoryWriteArgs(BaseModel):
return self
class MemoryWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryWriteArgs] = Field(min_length=1, max_length=20)
class MemoryForgetArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -70,6 +76,12 @@ class MemoryForgetArgs(BaseModel):
return self
class MemoryForgetBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryForgetArgs] = Field(min_length=1, max_length=20)
def _memory_error_output(
*,
tool_name: str,
@@ -149,28 +161,45 @@ def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool:
return False
def _compact_result_items(items: list[dict[str, object]]) -> str:
return ",".join(
"{" + ",".join(f"{key}={value}" for key, value in item.items()) + "}"
for item in items
)
async def memory_write(
memory_type: Annotated[
str,
Field(description="Memory type: user or work."),
] = "user",
user_content: Annotated[
UserMemoryContent | None,
Field(description="Patch payload for user memory content."),
] = None,
work_content: Annotated[
WorkProfileContent | None,
Field(description="Patch payload for work memory content."),
] = None,
operations: Annotated[
list[MemoryWriteArgs],
Field(
description=(
"Batch memory write operations. Each item must include memory_type and "
"the matching content object (user_content or work_content)."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Merge structured facts into user/work memory.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
user_content: Partial user-memory payload when ``memory_type='user'``.
work_content: Partial work-memory payload when ``memory_type='work'``.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput.
- success: ``result`` contains a compact status summary.
- failure: ``error`` contains structured code/message/retryable metadata.
"""
tool_name = "memory_write"
tool_call_args: dict[str, Any] = {
"memory_type": memory_type,
"user_content": user_content,
"work_content": work_content,
}
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
@@ -181,52 +210,117 @@ async def memory_write(
return runtime_error
try:
parsed_args = MemoryWriteArgs.model_validate(tool_call_args)
parsed_batch = MemoryWriteBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
existing = await service.get_memory_model(memory_type=parsed_args.memory_type)
success_count = 0
failed_count = 0
updated_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if op.memory_type == MemoryType.USER:
base_model = (
UserMemoryContent.model_validate(existing.content)
if existing is not None
else UserMemoryContent()
)
patch_model = cast(UserMemoryContent, op.user_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
updated = await service.update_user_memory(content=validated)
else:
base_model = (
WorkProfileContent.model_validate(existing.content)
if existing is not None
else WorkProfileContent()
)
patch_model = cast(WorkProfileContent, op.work_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
updated = await service.update_work_memory(content=validated)
if parsed_args.memory_type == MemoryType.USER:
base_model = (
UserMemoryContent.model_validate(existing.content)
if existing is not None
else UserMemoryContent()
)
patch_model = cast(UserMemoryContent, parsed_args.user_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
await service.update_user_memory(
content=validated,
)
else:
base_model = (
WorkProfileContent.model_validate(existing.content)
if existing is not None
else WorkProfileContent()
)
patch_model = cast(WorkProfileContent, parsed_args.work_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
await service.update_work_memory(
content=validated,
)
success_count += 1
updated_types.append(op.memory_type.value)
memory_id = str(
getattr(updated, "id", None)
or (getattr(existing, "id", None) if existing is not None else "")
or ""
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"memoryId": memory_id,
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
summary = f"status=success memory_type={parsed_args.memory_type.value}"
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"updated_types=[{','.join(updated_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch write failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001
@@ -241,22 +335,38 @@ async def memory_write(
async def memory_forget(
memory_type: Annotated[
str,
Field(description="Memory type: user or work."),
] = "user",
forget_paths: Annotated[
list[str] | None,
Field(description="Dot paths to remove from content."),
] = None,
operations: Annotated[
list[MemoryForgetArgs],
Field(
description=(
"Batch memory forget operations. Each item must include memory_type and "
"forget_paths."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Forget selected paths from user/work memory content.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
forget_paths: Dot-path list to remove from memory content.
Notes:
- Path root must belong to the target memory schema.
- The tool is idempotent; missing paths are skipped safely.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput with compact execution summary.
"""
tool_name = "memory_forget"
tool_call_args: dict[str, Any] = {
"memory_type": memory_type,
"forget_paths": forget_paths or [],
}
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
@@ -267,56 +377,120 @@ async def memory_forget(
return runtime_error
try:
parsed_args = MemoryForgetArgs.model_validate(tool_call_args)
parsed_batch = MemoryForgetBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
existing = await service.get_memory_model(memory_type=parsed_args.memory_type)
if existing is None:
summary = f"status=success memory_type={parsed_args.memory_type.value} forgotten=0"
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result=summary,
)
)
success_count = 0
failed_count = 0
forgotten_total = 0
processed_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if existing is None:
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": 0,
"memoryId": "",
}
)
continue
if parsed_args.memory_type == MemoryType.USER:
base_model = UserMemoryContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
parsed_args.forget_paths,
)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(
content=validated,
)
else:
base_model = WorkProfileContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
parsed_args.forget_paths,
)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(
content=validated,
)
if op.memory_type == MemoryType.USER:
base_model = UserMemoryContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(content=validated)
else:
base_model = WorkProfileContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(content=validated)
forgotten_total += len(removed_paths)
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": len(removed_paths),
"memoryId": str(getattr(existing, "id", "") or ""),
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status=success memory_type={parsed_args.memory_type.value} forgotten={len(removed_paths)} "
f"skipped=0"
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"forgotten={forgotten_total} "
f"processed_types=[{','.join(processed_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch forget failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001
+3 -1
View File
@@ -83,8 +83,10 @@ async def _dispatch_automation_run(
"content": input_text,
}
],
"tools": [],
"context": [],
"forwardedProps": {
"runtimeMode": RuntimeMode.AUTOMATION.value,
"runtime_mode": RuntimeMode.AUTOMATION.value,
},
}
@@ -1,4 +1,11 @@
input_template: 请基于最近两天用户聊天上下文提取用户记忆;如果已有记忆内容变化请更新;如果记忆已失效请执行遗忘。
input_template: |
你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具。
步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。
步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。
步骤3:对新增或变化的信息,调用 memory_write 执行写入。
步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串。
步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写。
输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。
enabled_tools:
- memory.write
- memory.forget
@@ -61,3 +61,14 @@ llms:
input_cost_per_token: 0.000002
output_cost_per_token: 0.000003
cache_hit_cost_per_token: 0.0000002
- model_code: qwen3.5-27b
factory_name: dashscope
litellm_model: dashscope/qwen3.5-27b
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.0000006
output_cost_per_token: 0.0000048
- max_prompt_tokens: 256000
input_cost_per_token: 0.0000018
output_cost_per_token: 0.0000144
-4
View File
@@ -32,10 +32,6 @@ class Memory(TimestampMixin, Base):
UUID(as_uuid=True),
nullable=False,
)
agent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
memory_type: Mapped[MemoryType] = mapped_column(
String(20),
nullable=False,
@@ -59,6 +59,10 @@ class AutomationJob(BaseModel):
created_at: datetime
updated_at: datetime
@property
def is_system(self) -> bool:
return self.bootstrap_key is not None
@classmethod
def from_orm(cls, obj: OrmAutomationJob) -> "AutomationJob":
return cls(
-1
View File
@@ -34,7 +34,6 @@ class MemoryModel(BaseModel):
id: UUID
owner_id: UUID
agent_id: UUID | None = None
memory_type: Literal["user", "work"]
content: UserMemoryContent | WorkProfileContent
status: MemoryStatus
+2 -1
View File
@@ -16,6 +16,7 @@ from core.agentscope.schemas.agui_input import (
)
from core.auth.models import CurrentUser
from core.logging import get_logger
from redis.exceptions import TimeoutError as RedisTimeoutError
from fastapi import (
APIRouter,
Depends,
@@ -180,7 +181,7 @@ async def stream_events(
last_event_id=cursor,
current_user=current_user,
)
except TimeoutError:
except (TimeoutError, RedisTimeoutError):
idle_polls += 1
yield ": keep-alive\n\n"
await asyncio.sleep(0.2)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from datetime import datetime, time
from typing import Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
from schemas.automation import (
AutomationJobConfig,
)
class AutomationJobResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: UUID
owner_id: UUID
bootstrap_key: str | None = None
title: str
schedule_type: ScheduleType
run_at: time
timezone: str
status: AutomationJobStatus
is_system: bool
config: AutomationJobConfig
next_run_at: datetime
last_run_at: datetime | None = None
created_at: datetime
updated_at: datetime
@classmethod
def from_orm(cls, obj: OrmAutomationJob) -> Self:
return cls(
id=obj.id,
owner_id=obj.owner_id,
bootstrap_key=obj.bootstrap_key,
title=obj.title,
schedule_type=obj.schedule_type,
run_at=obj.run_at.time(),
timezone=obj.timezone,
status=obj.status,
is_system=obj.bootstrap_key is not None,
config=AutomationJobConfig.model_validate(obj.config or {}),
next_run_at=obj.next_run_at,
last_run_at=obj.last_run_at,
created_at=obj.created_at,
updated_at=obj.updated_at,
)
class AutomationJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255)
schedule_type: ScheduleType
run_at: time = Field(..., description="Local time in HH:MM:SS format")
timezone: str = Field(..., min_length=1, max_length=50)
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
config: AutomationJobConfig
class AutomationJobUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255)
schedule_type: ScheduleType | None = None
run_at: time | None = None
timezone: str | None = Field(None, min_length=1, max_length=50)
status: AutomationJobStatus | None = None
config: AutomationJobConfig | None = None
class AutomationJobListResponse(BaseModel):
items: list[AutomationJobResponse]
@@ -59,16 +59,6 @@ def _patch_repositories(
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
async def _fake_stage_bit_map(self, *, session: object) -> dict[str, int]:
del self, session
return {"router": 16, "worker": 17, "memory": 18}
monkeypatch.setattr(
store_module.SqlAlchemyEventStore,
"_load_stage_visibility_bit_map",
_fake_stage_bit_map,
)
@pytest.mark.asyncio
async def test_store_persists_worker_output_with_answer_as_content(
@@ -113,7 +103,7 @@ async def test_store_persists_worker_output_with_answer_as_content(
assert metadata["agent_output"]["answer"] == "worker-answer"
assert metadata["agent_output"]["ui_hints"]["intent"] == "message"
assert append_kwargs["cost"] == Decimal("0.123")
assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 17))
assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 1))
assert captured["message_delta"] == 1
assert captured["token_delta"] == 8
@@ -153,3 +143,65 @@ async def test_store_persists_tool_output_with_summary_as_content(
== "status=success batch=1 success=1 failed=0 ids=[event-1]"
)
assert append_kwargs["visibility_mask"] == (1 << 0)
@pytest.mark.asyncio
async def test_store_persists_router_step_output_for_cost_tracking(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=10)
_patch_repositories(monkeypatch, captured, fake_chat_session)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
await store.persist(
{
"type": "STEP_FINISHED",
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-router-1",
"stepName": "router",
"_router_persist": {
"router_output": {
"normalized_task_input": {
"user_text": "安排明天会议",
"context_summary": "",
},
"key_entities": [],
"constraints": [],
"task_typing": {"primary": "scheduling"},
"execution_mode": "tool_assisted",
"result_typing": {"primary": "execution_report"},
"ui": {
"ui_mode": "none",
"ui_decision_reason": "单任务",
},
},
"response_metadata": {
"model": "doubao-seed-1-6-250615",
"inputTokens": 12,
"outputTokens": 8,
"cost": "0.01",
"latencyMs": 320,
},
},
}
)
append_kwargs = cast(dict[str, Any], captured["append_kwargs"])
assert append_kwargs["seq"] == 11
assert append_kwargs["content"] == ""
assert append_kwargs["model_code"] == "doubao-seed-1-6-250615"
assert append_kwargs["input_tokens"] == 12
assert append_kwargs["output_tokens"] == 8
assert append_kwargs["latency_ms"] == 320
assert append_kwargs["cost"] == Decimal("0.01")
assert append_kwargs["visibility_mask"] == 0
metadata = cast(dict[str, Any], append_kwargs["metadata"])
assert sorted(metadata.keys()) == ["agent_type", "router_agent_output", "run_id"]
assert metadata["agent_type"] == "router"
assert metadata["router_agent_output"]["execution_mode"] == "tool_assisted"
assert captured["message_delta"] == 1
assert captured["token_delta"] == 20
assert captured["cost_delta"] == Decimal("0.01")
@@ -114,6 +114,39 @@ def test_build_router_messages_skips_injection_when_context_last_is_user() -> No
assert msg.content == existing_context[i].content
def test_build_model_omits_none_generate_kwargs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
class _FakeOpenAIChatModel:
def __init__(self, **kwargs: object) -> None:
captured.update(kwargs)
monkeypatch.setattr(runner_module, "OpenAIChatModel", _FakeOpenAIChatModel)
runner = AgentScopeRunner()
stage_config = runner_module.SystemAgentRuntimeConfig(
agent_type=AgentType.ROUTER,
model_code="demo",
api_base_url="https://example.com",
api_key="test",
llm_config=runner_module.SystemAgentLLMConfig(
temperature=None,
max_tokens=None,
timeout_seconds=30.0,
),
)
model = runner._build_model(stage_config=stage_config)
assert isinstance(model, runner_module.TrackingChatModel)
assert captured["generate_kwargs"] == {
"timeout": 30.0,
"extra_body": {"enable_thinking": False},
}
@pytest.mark.asyncio
async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
runner = AgentScopeRunner()
@@ -27,6 +27,7 @@ class _FakeService:
created_request: Any = None
created_id: str = field(default_factory=lambda: str(uuid4()))
list_calls: list[dict[str, Any]] = field(default_factory=list)
deleted_ids: list[str] = field(default_factory=list)
async def list_paginated(
self, *, page: int, page_size: int, query: str | None = None
@@ -57,10 +58,20 @@ class _FakeService:
metadata=request.metadata,
)
async def delete(self, item_id: UUID) -> None:
self.deleted_ids.append(str(item_id))
async def share(self, item_id: UUID, request: Any) -> None:
if not hasattr(self, "share_calls"):
self.share_calls = []
self.share_calls.append({"item_id": str(item_id), "request": request})
@pytest.mark.asyncio
async def test_calendar_write_requires_runtime_context() -> None:
result = await calendar_module.calendar_write(operations=["create"])
result = await calendar_module.calendar_write(
operations=[calendar_module.CalendarWriteOperation(action="create")]
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
@@ -77,8 +88,12 @@ async def test_calendar_write_create_requires_start_at(
)
result = await calendar_module.calendar_write(
operations=["create"],
event_timezones=["Asia/Shanghai"],
operations=[
calendar_module.CalendarWriteOperation(
action="create",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
@@ -99,8 +114,12 @@ async def test_calendar_write_create_requires_event_timezone(
)
result = await calendar_module.calendar_write(
operations=["create"],
start_ats=["2026-03-16T09:00:00+08:00"],
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00+08:00",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
@@ -121,9 +140,13 @@ async def test_calendar_write_rejects_naive_start_at(
)
result = await calendar_module.calendar_write(
operations=["create"],
start_ats=["2026-03-16T09:00:00"],
event_timezones=["Asia/Shanghai"],
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
@@ -144,11 +167,15 @@ async def test_calendar_write_create_normalizes_to_utc(
)
result = await calendar_module.calendar_write(
operations=["create"],
titles=["晨会"],
start_ats=["2026-03-16T09:00:00+08:00"],
end_ats=["2026-03-16T10:00:00+08:00"],
event_timezones=["Asia/Shanghai"],
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
end_at="2026-03-16T10:00:00+08:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
@@ -166,7 +193,7 @@ async def test_calendar_write_create_normalizes_to_utc(
@pytest.mark.asyncio
async def test_calendar_write_rejects_misaligned_batch_lists(
async def test_calendar_write_batch_supports_create_and_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
@@ -175,17 +202,26 @@ async def test_calendar_write_rejects_misaligned_batch_lists(
)
result = await calendar_module.calendar_write(
operations=["create", "delete"],
start_ats=["2026-03-16T09:00:00+08:00"],
event_timezones=["Asia/Shanghai", "Asia/Shanghai"],
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
event_timezone="Asia/Shanghai",
),
calendar_module.CalendarWriteOperation(
action="delete",
event_id=str(uuid4()),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "长度必须与 operations 一致" in payload["error"]["message"]
assert payload["status"] == "success"
assert "success=2" in payload["result"]
assert len(fake_service.deleted_ids) == 1
@pytest.mark.asyncio
@@ -214,3 +250,45 @@ async def test_calendar_read_returns_structured_result_with_ids(
assert "status=" in payload["result"]
assert fake_service.created_id in payload["result"]
assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}]
@pytest.mark.asyncio
async def test_calendar_share_executes_with_valid_invitee(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
target_user_id = str(uuid4())
monkeypatch.setattr(
calendar_module,
"resolve_share_target_phone_map",
lambda user_ids: {target_user_id: "+8613900001234"}
if target_user_id in user_ids
else {},
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=[
calendar_module.CalendarShareInvitee(
userId=target_user_id,
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success invited_count=1")
assert "+8613900001234" in payload["result"]
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@@ -60,8 +60,12 @@ def _user_memory():
@pytest.mark.asyncio
async def test_memory_write_requires_runtime_context() -> None:
response = await memory_module.memory_write(
memory_type="user",
user_content=UserMemoryContent(interests=["跑步"]),
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
)
],
)
payload = _decode_tool_response(response)
assert payload["status"] == "failure"
@@ -78,15 +82,20 @@ async def test_memory_write_updates_user_content(
)
response = await memory_module.memory_write(
memory_type="user",
user_content=UserMemoryContent(interests=["阅读"]),
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "memory_type=user" in str(payload["result"])
assert "success=1" in str(payload["result"])
assert "updated_types=[user]" in str(payload["result"])
assert fake_service.updated_user == 1
@@ -101,13 +110,61 @@ async def test_memory_forget_updates_content_paths(
)
response = await memory_module.memory_forget(
memory_type="user",
forget_paths=["preferences.communication_style"],
operations=[
memory_module.MemoryForgetArgs(
memory_type=MemoryType.USER,
forget_paths=["preferences.communication_style"],
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "success=1" in str(payload["result"])
assert "forgotten=1" in str(payload["result"])
assert fake_service.updated_user == 1
@pytest.mark.asyncio
async def test_memory_write_partial_status_contains_error_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
call_count = 0
async def _update_user_memory(**kwargs):
nonlocal call_count
_ = kwargs
call_count += 1
if call_count == 2:
raise ValueError("invalid payload")
fake_service.updated_user += 1
return SimpleNamespace()
fake_service.update_user_memory = _update_user_memory # type: ignore[method-assign]
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
),
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "partial"
assert "status=partial" in str(payload["result"])
assert "failed=1" in str(payload["result"])
assert _payload_error_code(payload) in {"INVALID_ARGUMENT", "UNKNOWN_ERROR"}