refactor: 梳理规则体系并统一记忆与部署流程
This commit is contained in:
+47
-280
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user