Add architecture package and application skeleton
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
site/
|
||||
*.egg-info/
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.db
|
||||
*.sqlite3
|
||||
runtime/
|
||||
artifacts/
|
||||
@@ -1,68 +1,73 @@
|
||||
# AI Orchestrator
|
||||
|
||||
Отдельный проект для серверного AI-orchestrator, который управляет задачами, моделями, MCP-tools и local workers.
|
||||
Серверный runtime для AI-задач, моделей, MCP-tools и local workers.
|
||||
Проект отделен от `Local LLM Platform` и строится как самостоятельное orchestration-ядро.
|
||||
|
||||
## Главная идея
|
||||
|
||||
Этот проект не является частью `Local LLM Platform`.
|
||||
|
||||
`Local LLM Platform` отвечает за:
|
||||
- локальные модели;
|
||||
- inference;
|
||||
- registry;
|
||||
- GPU profiles;
|
||||
- model deployment;
|
||||
- smoke tests моделей.
|
||||
## Цели
|
||||
|
||||
`AI Orchestrator` отвечает за:
|
||||
- общение с пользователем;
|
||||
- построение плана/графа выполнения;
|
||||
- вызов моделей;
|
||||
- вызов MCP-инструментов;
|
||||
- работу с local worker;
|
||||
- подтверждения действий;
|
||||
- логи;
|
||||
|
||||
- прием пользовательских задач;
|
||||
- построение execution graph;
|
||||
- вызов моделей через model router;
|
||||
- вызов MCP tools;
|
||||
- dispatch команд в local worker;
|
||||
- policy-driven подтверждения;
|
||||
- event log, audit и progress stream;
|
||||
- fallback между weak/strong/vision моделями.
|
||||
|
||||
## Ключевой принцип
|
||||
## Принципы
|
||||
|
||||
В коде нет жестких запретов на действия.
|
||||
- никаких hardcoded bans;
|
||||
- все ограничения идут через project policy;
|
||||
- worker не планирует задачу и не принимает интеллектуальных решений;
|
||||
- 1С не попадает в ядро и подключается только через MCP;
|
||||
- локальный Docker не используется как обязательный dev path.
|
||||
|
||||
Система должна работать через настраиваемую политику проекта:
|
||||
## Архитектура
|
||||
|
||||
- `full_auto` — выполнять без подтверждений;
|
||||
- `confirm` — спрашивать подтверждение для действий, отмеченных политикой;
|
||||
- `manual` — всегда показывать план и ждать запуска;
|
||||
- `disabled` — компонент отключен настройкой.
|
||||
Проект разбит на 4 слоя:
|
||||
|
||||
Важно: `disabled` — это не hardcoded ban, а выбранная настройка проекта.
|
||||
- `domain` — сущности, state machines, policy/value objects, события;
|
||||
- `application` — use cases и orchestration services;
|
||||
- `infrastructure` — storage, config, policy adapter, provider gateways;
|
||||
- `delivery` — HTTP API, SSE, worker transport adapters.
|
||||
|
||||
## Базовая схема
|
||||
Подробные решения зафиксированы в:
|
||||
|
||||
```text
|
||||
User UI / API
|
||||
↓
|
||||
Conversation Manager
|
||||
↓
|
||||
Server Orchestrator
|
||||
↓
|
||||
Execution Graph
|
||||
├─ Planner
|
||||
├─ Workers
|
||||
├─ Reviewer
|
||||
└─ Finalizer
|
||||
↓
|
||||
Model Router
|
||||
├─ weak model: local
|
||||
├─ strong model: local or external
|
||||
└─ vision model: local or external
|
||||
↓
|
||||
Tools
|
||||
├─ MCP client
|
||||
├─ remote MCP servers
|
||||
└─ local worker protocol
|
||||
- [ADR](docs/adr/001-runtime-stack.md)
|
||||
- [Domain model](docs/architecture/04_domain_model.md)
|
||||
- [State machines](docs/architecture/05_state_machines.md)
|
||||
- [Storage schema](docs/storage/01_schema.md)
|
||||
|
||||
## Текущий статус
|
||||
|
||||
В репозитории уже собраны:
|
||||
|
||||
- архитектурный пакет с ADR;
|
||||
- доменная модель и state machines;
|
||||
- draft persistent storage schema;
|
||||
- внутренние порты orchestrator;
|
||||
- кодовый каркас `src/ai_orchestrator`;
|
||||
- базовый FastAPI delivery layer;
|
||||
- in-memory adapters для безопасного старта;
|
||||
- unit tests на базовые состояния и policy.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
```bash
|
||||
python -m pip install -e .[dev]
|
||||
python -m uvicorn ai_orchestrator.main:app --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
## Что должен сделать Codex
|
||||
## Структура
|
||||
|
||||
Начать с документа `docs/codex/CODEX_TASK.md`.
|
||||
```text
|
||||
src/ai_orchestrator/
|
||||
domain/
|
||||
application/
|
||||
infrastructure/
|
||||
delivery/
|
||||
```
|
||||
|
||||
Исходные папки `src/orchestrator`, `src/policy` и другие пока остаются как legacy-концептуальные маркеры из исходного blueprint.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# ADR 001: Runtime Stack
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
AI Orchestrator должен быть:
|
||||
|
||||
- независимым от Local LLM Platform;
|
||||
- пригодным для долгоживущих task runtime;
|
||||
- удобным для строгой типизации контрактов;
|
||||
- удобным для async I/O: models, MCP, worker connections, event streams;
|
||||
- запускаемым без Docker на локальной машине;
|
||||
- переносимым на удаленное окружение, включая `docker-test`.
|
||||
|
||||
## Decision
|
||||
|
||||
Основной стек:
|
||||
|
||||
- язык: Python 3.13+;
|
||||
- HTTP API: FastAPI;
|
||||
- конфиги и схемы запроса/ответа: Pydantic v2;
|
||||
- app settings: `pydantic-settings`;
|
||||
- конфиги проекта: YAML;
|
||||
- event streaming для UI: SSE как основной transport;
|
||||
- worker transport: WebSocket;
|
||||
- structured logging: JSON lines через стандартный logging layer и event envelopes;
|
||||
- тесты: `pytest` + `pytest-asyncio`;
|
||||
- runtime packaging: стандартный `pyproject.toml`.
|
||||
|
||||
## Why This Stack
|
||||
|
||||
Python хорошо подходит для orchestration-heavy систем, где важнее:
|
||||
|
||||
- строгое моделирование состояния;
|
||||
- быстрая интеграция с внешними AI/MCP endpoint;
|
||||
- асинхронный I/O;
|
||||
- прозрачные схемы данных;
|
||||
- простая локальная разработка без контейнеров.
|
||||
|
||||
FastAPI выбран как delivery adapter, а не как центр архитектуры.
|
||||
Доменные модели и application services не должны зависеть от FastAPI.
|
||||
|
||||
## Consequences
|
||||
|
||||
Плюсы:
|
||||
|
||||
- быстрый путь к строгим контрактам;
|
||||
- хорошая ergonomics для async adapters;
|
||||
- удобный локальный запуск без Docker;
|
||||
- понятный переход к PostgreSQL и production deployment.
|
||||
|
||||
Минусы:
|
||||
|
||||
- нужен дисциплинированный layering, чтобы не “утонуть” в framework-driven code;
|
||||
- CPU-heavy задачи должны оставаться вне основного request loop.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- локальный Docker как обязательный dev path;
|
||||
- тяжелая зависимость от конкретной ORM на уровне domain;
|
||||
- framework-first архитектура.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR 002: Storage And Persistence Model
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Проект не должен начинаться с чисто временного `in-memory` мышления, иначе потом придется переделывать:
|
||||
|
||||
- task lifecycle;
|
||||
- event log;
|
||||
- confirmations;
|
||||
- worker session tracking;
|
||||
- replay и audit.
|
||||
|
||||
## Decision
|
||||
|
||||
Хранилище проектируется как persistent-first:
|
||||
|
||||
- production target: PostgreSQL;
|
||||
- local development fallback: SQLite без Docker;
|
||||
- repository interfaces определяются в application/domain boundary;
|
||||
- materialized current state хранится отдельно от append-only event log;
|
||||
- критические переходы состояний должны логироваться как события.
|
||||
|
||||
## Data Model Principles
|
||||
|
||||
1. `tasks`, `task_nodes`, `confirmations`, `worker_sessions` хранят текущее состояние.
|
||||
2. `task_events` хранит audit trail и feed для replay/debugging.
|
||||
3. Вызовы моделей, MCP и worker сохраняются как отдельные invocation records.
|
||||
4. Артефакты хранят metadata в БД, а payload может лежать во внешнем файловом хранилище.
|
||||
|
||||
## Consequences
|
||||
|
||||
Плюсы:
|
||||
|
||||
- можно строить UI progress и audit без догадок;
|
||||
- проще реализовать replay/resume;
|
||||
- нет боли миграции с “простых dict” на реальную БД.
|
||||
|
||||
Минусы:
|
||||
|
||||
- немного более сложный старт;
|
||||
- нужно заранее аккуратно продумать схему.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# ADR 003: User Event Stream Transport
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
UI требует поток событий по task execution:
|
||||
|
||||
- `task_started`
|
||||
- `node_started`
|
||||
- `node_completed`
|
||||
- `confirmation_required`
|
||||
- `task_completed`
|
||||
- `task_failed`
|
||||
|
||||
В исходных документах упомянуты `SSE/WebSocket`, но как основной transport нужно выбрать один.
|
||||
|
||||
## Decision
|
||||
|
||||
Основной transport для пользовательского event stream: Server-Sent Events.
|
||||
|
||||
WebSocket не исключается, но считается вторичным adapter для будущих realtime-потребностей.
|
||||
|
||||
## Rationale
|
||||
|
||||
SSE проще для:
|
||||
|
||||
- однонаправленного server-to-client прогресса;
|
||||
- проксирования;
|
||||
- reconnect semantics;
|
||||
- дебага и совместимости с обычными HTTP-инструментами.
|
||||
|
||||
WebSocket нужен не UI в первую очередь, а local worker transport.
|
||||
|
||||
## Consequences
|
||||
|
||||
`GET /tasks/{id}/events` фиксируется как SSE endpoint.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR 004: Local Worker Transport
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Local Worker работает на машине пользователя и должен:
|
||||
|
||||
- сам инициировать соединение;
|
||||
- не требовать входящего порта на пользовательском ПК;
|
||||
- поддерживать прогресс, heartbeat и dispatch commands.
|
||||
|
||||
## Decision
|
||||
|
||||
Основной transport для worker gateway: outbound WebSocket session от worker к серверу.
|
||||
|
||||
## Required Semantics
|
||||
|
||||
- registration handshake;
|
||||
- capability advertisement;
|
||||
- heartbeat;
|
||||
- command dispatch;
|
||||
- progress events;
|
||||
- result envelope;
|
||||
- cancellation;
|
||||
- reconnect with new session id.
|
||||
|
||||
## Consequences
|
||||
|
||||
Worker transport остается отдельным delivery/infrastructure adapter и не врастает в orchestration core.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR 005: Policy Evaluation Model
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Ключевой принцип проекта: без hardcoded bans.
|
||||
Система должна принимать решения через policy engine, а не через встроенные запреты по имени tool или по содержимому команды.
|
||||
|
||||
## Decision
|
||||
|
||||
Policy engine работает в 4 шага:
|
||||
|
||||
1. request/action нормализуется в `ActionDescriptor`;
|
||||
2. descriptor классифицируется по `resource` и `risk_level`;
|
||||
3. policy evaluator находит эффективное правило;
|
||||
4. возвращается `PolicyDecision`.
|
||||
|
||||
## Core Types
|
||||
|
||||
`resource`:
|
||||
|
||||
- `filesystem`
|
||||
- `shell`
|
||||
- `sql`
|
||||
- `mcp`
|
||||
- `external_models`
|
||||
- `desktop`
|
||||
- `browser`
|
||||
- `network`
|
||||
- `cost`
|
||||
- `system`
|
||||
|
||||
`risk_level`:
|
||||
|
||||
- `safe`
|
||||
- `write`
|
||||
- `destructive`
|
||||
- `system`
|
||||
- `cost`
|
||||
|
||||
`decision`:
|
||||
|
||||
- `allow`
|
||||
- `confirm`
|
||||
- `manual`
|
||||
- `disabled_by_config`
|
||||
|
||||
## Important Rule
|
||||
|
||||
Policy engine не получает “tool name only” как источник истины.
|
||||
Он должен опираться на action classification и metadata от tool/model/worker adapters.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR 006: Event Taxonomy And Correlation
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Оркестратор должен быть отлаживаемым и воспроизводимым.
|
||||
Для этого все значимые переходы должны иметь единый event model.
|
||||
|
||||
## Decision
|
||||
|
||||
Все события включают:
|
||||
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `occurred_at`
|
||||
- `task_id`
|
||||
- `conversation_id` when available
|
||||
- `node_id` when available
|
||||
- `correlation_id`
|
||||
- `causation_id`
|
||||
- `payload`
|
||||
|
||||
## Event Families
|
||||
|
||||
1. Task lifecycle
|
||||
2. Node lifecycle
|
||||
3. Policy decisions
|
||||
4. Confirmation lifecycle
|
||||
5. Model invocation lifecycle
|
||||
6. Tool invocation lifecycle
|
||||
7. Worker session lifecycle
|
||||
8. Worker command lifecycle
|
||||
9. Finalizer/result lifecycle
|
||||
10. System warnings/errors
|
||||
|
||||
## Consequences
|
||||
|
||||
UI, audit log, replay tooling и debugging должны читать одну и ту же taxonomy, а не разрозненные лог-сообщения.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR 007: Model Router Fallback Semantics
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
В проекте уже зафиксирована идея:
|
||||
|
||||
- сначала weak;
|
||||
- затем retry weak;
|
||||
- затем strong fallback;
|
||||
- external models не запрещаются кодом, а регулируются конфигом и policy.
|
||||
|
||||
## Decision
|
||||
|
||||
Fallback pipeline:
|
||||
|
||||
1. slot resolution выбирает provider и model для requested slot;
|
||||
2. weak model вызывается первой;
|
||||
3. если ответ невалиден по output contract, выполняется ограниченный retry weak;
|
||||
4. если weak исчерпан, router проверяет доступность strong;
|
||||
5. если strong разрешен config+policy, выполняется strong fallback;
|
||||
6. если strong недоступен, возвращается structured degraded result.
|
||||
|
||||
## Notes
|
||||
|
||||
- “невалиден” означает нарушение response schema, parser failure или явный quality rejection;
|
||||
- fallback trail сохраняется в invocation log;
|
||||
- внешняя strong model не вызывается, если `allow_external_models=false` или policy запрещает.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Доменная модель
|
||||
|
||||
## Слои
|
||||
|
||||
### Domain
|
||||
|
||||
Чистые сущности, value objects, state machines, классификация рисков, события.
|
||||
|
||||
### Application
|
||||
|
||||
Use cases:
|
||||
|
||||
- create_task
|
||||
- submit_chat_message
|
||||
- plan_task
|
||||
- execute_ready_nodes
|
||||
- request_confirmation
|
||||
- approve_confirmation
|
||||
- reject_confirmation
|
||||
- register_worker
|
||||
- ingest_worker_result
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- storage adapters
|
||||
- config loader
|
||||
- event publisher
|
||||
- model providers
|
||||
- MCP transport
|
||||
- worker gateway
|
||||
- artifact persistence
|
||||
|
||||
### Delivery
|
||||
|
||||
- HTTP API
|
||||
- SSE task events
|
||||
- worker WebSocket session endpoint
|
||||
|
||||
## Главные сущности
|
||||
|
||||
### ProjectConfig
|
||||
|
||||
Описывает:
|
||||
|
||||
- model slots
|
||||
- policy modes
|
||||
- execution limits
|
||||
- enabled MCP servers
|
||||
- worker access rules
|
||||
|
||||
### Conversation
|
||||
|
||||
Контейнер пользовательского диалога и связанных задач.
|
||||
|
||||
### Task
|
||||
|
||||
Единица выполнения пользовательской цели.
|
||||
|
||||
Поля:
|
||||
|
||||
- `id`
|
||||
- `project_id`
|
||||
- `conversation_id`
|
||||
- `goal`
|
||||
- `inputs`
|
||||
- `status`
|
||||
- `requested_mode`
|
||||
- `effective_mode`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `result_summary`
|
||||
|
||||
### ExecutionGraph
|
||||
|
||||
Набор node definitions и execution dependencies для конкретной task.
|
||||
|
||||
### Node
|
||||
|
||||
Типы:
|
||||
|
||||
- `planner`
|
||||
- `model_call`
|
||||
- `tool_call`
|
||||
- `local_worker_call`
|
||||
- `reviewer`
|
||||
- `finalizer`
|
||||
- `confirmation`
|
||||
|
||||
### ConfirmationRequest
|
||||
|
||||
Отдельная сущность для действий, требующих подтверждения.
|
||||
|
||||
### WorkerSession
|
||||
|
||||
Онлайн-сессия thin local worker.
|
||||
|
||||
### ModelInvocation
|
||||
|
||||
Record вызова model router/provider.
|
||||
|
||||
### ToolInvocation
|
||||
|
||||
Record вызова MCP tool или worker command.
|
||||
|
||||
### Artifact
|
||||
|
||||
Материализованный результат: patch, file diff, report, log bundle.
|
||||
|
||||
### Event
|
||||
|
||||
Audit и UI-событие в единой taxonomy.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# State Machines
|
||||
|
||||
## TaskStatus
|
||||
|
||||
```text
|
||||
created
|
||||
-> planned
|
||||
-> running
|
||||
-> waiting_confirmation
|
||||
-> waiting_manual
|
||||
-> completed
|
||||
-> failed
|
||||
-> cancelled
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- `created -> planned`: planner graph создан;
|
||||
- `planned -> running`: есть готовые к исполнению nodes;
|
||||
- `running -> waiting_confirmation`: execution остановлен на pending approval;
|
||||
- `running -> waiting_manual`: policy/manual gate остановил автостарт;
|
||||
- `running -> completed`: finalizer сформировал итог;
|
||||
- `running -> failed`: unrecoverable error;
|
||||
- `waiting_confirmation -> running`: approval получен;
|
||||
- `waiting_manual -> running`: пользователь вручную продолжил выполнение.
|
||||
|
||||
## NodeStatus
|
||||
|
||||
```text
|
||||
pending
|
||||
-> ready
|
||||
-> running
|
||||
-> waiting_confirmation
|
||||
-> completed
|
||||
-> failed
|
||||
-> skipped
|
||||
-> cancelled
|
||||
```
|
||||
|
||||
## ConfirmationStatus
|
||||
|
||||
```text
|
||||
pending
|
||||
-> approved
|
||||
-> rejected
|
||||
-> expired
|
||||
-> cancelled
|
||||
```
|
||||
|
||||
## WorkerSessionStatus
|
||||
|
||||
```text
|
||||
connecting
|
||||
-> online
|
||||
-> busy
|
||||
-> stale
|
||||
-> disconnected
|
||||
```
|
||||
|
||||
## Retry Principles
|
||||
|
||||
- retry выполняется только для явно retryable failures;
|
||||
- retry history фиксируется в node attempts и event log;
|
||||
- confirmation-required state не считается failure;
|
||||
- idempotency must be explicit for side-effecting nodes.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Internal Interfaces
|
||||
|
||||
## Orchestrator Application Ports
|
||||
|
||||
### TaskRepository
|
||||
|
||||
- create task
|
||||
- update task
|
||||
- get task by id
|
||||
- list active tasks
|
||||
|
||||
### GraphRepository
|
||||
|
||||
- save graph
|
||||
- get graph
|
||||
- update node state
|
||||
- list ready nodes
|
||||
|
||||
### ConfirmationRepository
|
||||
|
||||
- create confirmation
|
||||
- get confirmation
|
||||
- resolve confirmation
|
||||
|
||||
### WorkerRepository
|
||||
|
||||
- register session
|
||||
- update heartbeat
|
||||
- assign command
|
||||
- complete command
|
||||
|
||||
### EventStore
|
||||
|
||||
- append event
|
||||
- list events by task
|
||||
|
||||
### PolicyEvaluator
|
||||
|
||||
- evaluate action descriptor against project policy
|
||||
|
||||
### ModelRouter
|
||||
|
||||
- execute model request and return structured invocation result
|
||||
|
||||
### ToolGateway
|
||||
|
||||
- call MCP tool and return normalized result
|
||||
|
||||
### WorkerGateway
|
||||
|
||||
- dispatch command to worker and receive normalized result
|
||||
|
||||
## Design Rule
|
||||
|
||||
Application services зависят только от этих портов.
|
||||
Ни один use case не должен напрямую импортировать HTTP handlers, DB session objects или конкретный MCP/WebSocket client.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Error Model
|
||||
|
||||
## Error Families
|
||||
|
||||
### ValidationError
|
||||
|
||||
Некорректный вход, schema mismatch, invalid command envelope.
|
||||
|
||||
### PolicyError
|
||||
|
||||
Действие запрещено текущей конфигурацией или требует manual/confirm остановки.
|
||||
|
||||
### RetryableInfrastructureError
|
||||
|
||||
Временная ошибка транспорта, timeout, transient upstream failure.
|
||||
|
||||
### NonRetryableInfrastructureError
|
||||
|
||||
Постоянная ошибка конфигурации, unsupported capability, broken contract.
|
||||
|
||||
### ExecutionError
|
||||
|
||||
Ошибка бизнес-исполнения конкретного node.
|
||||
|
||||
### CancellationError
|
||||
|
||||
Task или node остановлены по explicit cancel.
|
||||
|
||||
## User-Facing Behavior
|
||||
|
||||
- validation и policy ошибки должны быть ясными и краткими;
|
||||
- infra ошибки должны сохранять technical details в logs/events;
|
||||
- task итог должен различать `failed` и `waiting for action`.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Execution Model
|
||||
|
||||
## Базовый цикл
|
||||
|
||||
1. Пользователь создает `Task`.
|
||||
2. Planner materializes `ExecutionGraph`.
|
||||
3. Graph engine отмечает `ready` узлы.
|
||||
4. Orchestrator выбирает runner для каждого ready node.
|
||||
5. Перед side-effecting action выполняется policy evaluation.
|
||||
6. Если нужен `confirm`, создается `ConfirmationRequest` и graph останавливается.
|
||||
7. Если action разрешен, runner выполняет node.
|
||||
8. Node результат сохраняется как output + event trail + optional artifacts.
|
||||
9. Reviewer может валидировать промежуточный результат.
|
||||
10. Finalizer формирует user-facing result и cards.
|
||||
|
||||
## Node Families
|
||||
|
||||
### Planner
|
||||
|
||||
Создает или пересобирает graph. Не выполняет side effects.
|
||||
|
||||
### Model Call
|
||||
|
||||
Использует `ModelRouter`.
|
||||
Может инициировать fallback, но только внутри router contract.
|
||||
|
||||
### Tool Call
|
||||
|
||||
Использует `ToolGateway` для MCP.
|
||||
|
||||
### Local Worker Call
|
||||
|
||||
Использует `WorkerGateway` для thin executor.
|
||||
|
||||
### Reviewer
|
||||
|
||||
Системный шаг quality gate:
|
||||
|
||||
- schema validation
|
||||
- semantic validation
|
||||
- retry recommendation
|
||||
- escalate to strong model
|
||||
|
||||
### Finalizer
|
||||
|
||||
Преобразует execution result в user result:
|
||||
|
||||
- message text
|
||||
- cards
|
||||
- artifacts metadata
|
||||
- summary
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- workers не создают новые workers;
|
||||
- model/router adapters не меняют graph напрямую;
|
||||
- только orchestrator меняет task/node state;
|
||||
- side effects всегда проходят через policy decision;
|
||||
- event log пишется на каждом значимом переходе.
|
||||
|
||||
## Manual And Resume
|
||||
|
||||
`manual` означает, что orchestrator не стартует следующий шаг автоматически.
|
||||
Task остается в `waiting_manual` до явного resume action.
|
||||
|
||||
## Replay
|
||||
|
||||
Replay не означает повтор side effects автоматически.
|
||||
Replay должен уметь:
|
||||
|
||||
- восстанавливать graph state из persisted state;
|
||||
- переиздавать progress view;
|
||||
- запускать retry только для разрешенных retryable nodes.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Execution Backlog
|
||||
|
||||
## Package A: Architecture Freeze
|
||||
|
||||
- [x] runtime ADR
|
||||
- [x] storage ADR
|
||||
- [x] event stream ADR
|
||||
- [x] worker transport ADR
|
||||
- [x] policy evaluation ADR
|
||||
- [x] event taxonomy ADR
|
||||
- [x] model fallback ADR
|
||||
- [x] domain model
|
||||
- [x] state machines
|
||||
- [x] storage schema draft
|
||||
- [x] internal interfaces
|
||||
- [x] error model
|
||||
- [x] execution model
|
||||
|
||||
## Package B: Core Hardening
|
||||
|
||||
- [ ] graph scheduler service
|
||||
- [ ] node runner abstraction
|
||||
- [ ] reviewer contract
|
||||
- [ ] finalizer contract
|
||||
- [ ] resume/cancel use cases
|
||||
- [ ] idempotency policy
|
||||
- [ ] retry policy matrix
|
||||
|
||||
## Package C: Persistence
|
||||
|
||||
- [ ] SQLAlchemy models
|
||||
- [ ] repositories for PostgreSQL/SQLite
|
||||
- [ ] migrations
|
||||
- [ ] artifact metadata persistence
|
||||
- [ ] event store queries with pagination
|
||||
|
||||
## Package D: Integrations
|
||||
|
||||
- [ ] model router providers
|
||||
- [ ] MCP transport adapter
|
||||
- [ ] worker WebSocket gateway
|
||||
- [ ] heartbeat monitor
|
||||
- [ ] capability-aware dispatch
|
||||
|
||||
## Package E: Delivery
|
||||
|
||||
- [ ] typed worker registration schema
|
||||
- [ ] task event DTO normalization
|
||||
- [ ] confirmation cards
|
||||
- [ ] progress cards
|
||||
- [ ] artifact cards
|
||||
- [ ] error cards
|
||||
|
||||
## Package F: Validation
|
||||
|
||||
- [ ] contract tests
|
||||
- [ ] scenario tests
|
||||
- [ ] smoke suite
|
||||
- [ ] static checks in CI
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
|
||||
## GET /tasks/{task_id}/events
|
||||
|
||||
SSE/WebSocket stream событий:
|
||||
Primary transport: SSE stream событий.
|
||||
|
||||
WebSocket может быть добавлен как secondary adapter, но contract-first путь для UI: SSE.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -87,6 +89,24 @@ SSE/WebSocket stream событий:
|
||||
}
|
||||
```
|
||||
|
||||
## GET /workers
|
||||
|
||||
Список известных worker sessions.
|
||||
|
||||
```json
|
||||
{
|
||||
"workers": [
|
||||
{
|
||||
"session_id": "wrk_1",
|
||||
"worker_id": "worker_home_pc",
|
||||
"name": "Home PC",
|
||||
"status": "online",
|
||||
"capabilities": ["file.read", "command.run"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## POST /workers/register
|
||||
|
||||
Local worker регистрируется на сервере.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Persistent Storage Schema Draft
|
||||
|
||||
## Database Target
|
||||
|
||||
- production: PostgreSQL
|
||||
- local development: SQLite
|
||||
|
||||
## Tables
|
||||
|
||||
### projects
|
||||
|
||||
- `project_id` pk
|
||||
- `name`
|
||||
- `config_blob`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### conversations
|
||||
|
||||
- `conversation_id` pk
|
||||
- `project_id`
|
||||
- `title`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### messages
|
||||
|
||||
- `message_id` pk
|
||||
- `conversation_id`
|
||||
- `role`
|
||||
- `content`
|
||||
- `attachments_json`
|
||||
- `created_at`
|
||||
|
||||
### tasks
|
||||
|
||||
- `task_id` pk
|
||||
- `project_id`
|
||||
- `conversation_id`
|
||||
- `goal`
|
||||
- `inputs_json`
|
||||
- `status`
|
||||
- `requested_mode`
|
||||
- `effective_mode`
|
||||
- `current_node_id`
|
||||
- `result_summary_json`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### task_graphs
|
||||
|
||||
- `task_id` pk
|
||||
- `graph_version`
|
||||
- `graph_metadata_json`
|
||||
- `created_at`
|
||||
|
||||
### task_nodes
|
||||
|
||||
- `node_id` pk
|
||||
- `task_id`
|
||||
- `node_type`
|
||||
- `status`
|
||||
- `input_json`
|
||||
- `output_json`
|
||||
- `dependencies_json`
|
||||
- `assigned_runner`
|
||||
- `attempts`
|
||||
- `retryable`
|
||||
- `timeout_ms`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### task_events
|
||||
|
||||
- `event_id` pk
|
||||
- `task_id`
|
||||
- `conversation_id`
|
||||
- `node_id`
|
||||
- `event_type`
|
||||
- `correlation_id`
|
||||
- `causation_id`
|
||||
- `payload_json`
|
||||
- `occurred_at`
|
||||
|
||||
### confirmations
|
||||
|
||||
- `confirmation_id` pk
|
||||
- `task_id`
|
||||
- `node_id`
|
||||
- `status`
|
||||
- `scope`
|
||||
- `decision_json`
|
||||
- `preview_json`
|
||||
- `comment`
|
||||
- `created_at`
|
||||
- `resolved_at`
|
||||
|
||||
### worker_sessions
|
||||
|
||||
- `session_id` pk
|
||||
- `worker_id`
|
||||
- `name`
|
||||
- `machine`
|
||||
- `os`
|
||||
- `version`
|
||||
- `status`
|
||||
- `capabilities_json`
|
||||
- `last_heartbeat_at`
|
||||
- `current_task_id`
|
||||
- `connected_at`
|
||||
- `disconnected_at`
|
||||
|
||||
### model_invocations
|
||||
|
||||
- `invocation_id` pk
|
||||
- `task_id`
|
||||
- `node_id`
|
||||
- `slot`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `status`
|
||||
- `request_json`
|
||||
- `response_json`
|
||||
- `usage_json`
|
||||
- `fallback_from_invocation_id`
|
||||
- `created_at`
|
||||
|
||||
### tool_invocations
|
||||
|
||||
- `invocation_id` pk
|
||||
- `task_id`
|
||||
- `node_id`
|
||||
- `source_type`
|
||||
- `source_id`
|
||||
- `tool_name`
|
||||
- `status`
|
||||
- `request_json`
|
||||
- `response_json`
|
||||
- `created_at`
|
||||
|
||||
### artifacts
|
||||
|
||||
- `artifact_id` pk
|
||||
- `task_id`
|
||||
- `node_id`
|
||||
- `artifact_type`
|
||||
- `storage_uri`
|
||||
- `metadata_json`
|
||||
- `created_at`
|
||||
|
||||
## Required Indexes
|
||||
|
||||
- `tasks(status, updated_at)`
|
||||
- `task_nodes(task_id, status)`
|
||||
- `task_events(task_id, occurred_at)`
|
||||
- `confirmations(status, created_at)`
|
||||
- `worker_sessions(status, last_heartbeat_at)`
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Testing Strategy
|
||||
|
||||
## Layers
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- state transitions
|
||||
- policy evaluation
|
||||
- graph readiness
|
||||
- fallback logic
|
||||
|
||||
### Contract Tests
|
||||
|
||||
- API schemas
|
||||
- worker envelopes
|
||||
- MCP result normalization
|
||||
- model router output contracts
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- repository implementations
|
||||
- event streaming
|
||||
- config loader
|
||||
- storage adapters
|
||||
|
||||
### Scenario Tests
|
||||
|
||||
- create task -> plan -> execute -> finalize
|
||||
- confirm flow
|
||||
- weak retry then strong fallback
|
||||
- worker result ingestion
|
||||
|
||||
### Smoke Tests
|
||||
|
||||
Обязательный список зафиксирован в [tests/smoke/SMOKE_TESTS.md](/Z:/codex/ai_orchestrator/tests/smoke/SMOKE_TESTS.md).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ai-orchestrator"
|
||||
version = "0.1.0"
|
||||
description = "Server-side AI orchestrator/runtime for tasks, models, MCP tools, and local workers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [
|
||||
{ name = "Mikhail" }
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"pydantic>=2.9.0,<3.0.0",
|
||||
"pydantic-settings>=2.5.0,<3.0.0",
|
||||
"pyyaml>=6.0.2,<7.0.0",
|
||||
"sse-starlette>=2.1.3,<3.0.0",
|
||||
"uvicorn>=0.32.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0,<9.0.0",
|
||||
"pytest-asyncio>=0.24.0,<1.0.0",
|
||||
"httpx>=0.27.0,<1.0.0",
|
||||
"ruff>=0.6.0,<1.0.0",
|
||||
]
|
||||
db = [
|
||||
"sqlalchemy>=2.0.36,<3.0.0",
|
||||
"alembic>=1.13.0,<2.0.0",
|
||||
"asyncpg>=0.30.0,<1.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
|
||||
@@ -0,0 +1,2 @@
|
||||
"""AI Orchestrator package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Application layer."""
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ai_orchestrator.domain.events import DomainEvent
|
||||
from ai_orchestrator.domain.models import (
|
||||
ActionDescriptor,
|
||||
ConfirmationRequest,
|
||||
ExecutionGraph,
|
||||
PolicyDecision,
|
||||
Task,
|
||||
WorkerSession,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModelInvocationResult:
|
||||
status: str
|
||||
provider: str
|
||||
model: str
|
||||
message: dict[str, Any]
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
usage: dict[str, Any] = field(default_factory=dict)
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolInvocationResult:
|
||||
status: str
|
||||
content: dict[str, Any]
|
||||
artifacts: list[dict[str, Any]] = field(default_factory=list)
|
||||
logs: list[str] = field(default_factory=list)
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskRepository(Protocol):
|
||||
def create(self, task: Task) -> Task: ...
|
||||
def save(self, task: Task) -> Task: ...
|
||||
def get(self, task_id: str) -> Task | None: ...
|
||||
|
||||
|
||||
class GraphRepository(Protocol):
|
||||
def save(self, graph: ExecutionGraph) -> ExecutionGraph: ...
|
||||
def get(self, task_id: str) -> ExecutionGraph | None: ...
|
||||
|
||||
|
||||
class ConfirmationRepository(Protocol):
|
||||
def create(self, confirmation: ConfirmationRequest) -> ConfirmationRequest: ...
|
||||
def get(self, confirmation_id: str) -> ConfirmationRequest | None: ...
|
||||
def save(self, confirmation: ConfirmationRequest) -> ConfirmationRequest: ...
|
||||
|
||||
|
||||
class WorkerRepository(Protocol):
|
||||
def save(self, worker: WorkerSession) -> WorkerSession: ...
|
||||
def get(self, session_id: str) -> WorkerSession | None: ...
|
||||
def list_active(self) -> list[WorkerSession]: ...
|
||||
|
||||
|
||||
class EventStore(Protocol):
|
||||
def append(self, event: DomainEvent) -> DomainEvent: ...
|
||||
def list_by_task(self, task_id: str) -> list[DomainEvent]: ...
|
||||
|
||||
|
||||
class PolicyEvaluator(Protocol):
|
||||
def evaluate(self, action: ActionDescriptor, project_id: str) -> PolicyDecision: ...
|
||||
|
||||
|
||||
class ModelRouter(Protocol):
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
slot: str,
|
||||
messages: list[dict[str, Any]],
|
||||
task_context: dict[str, Any],
|
||||
) -> ModelInvocationResult: ...
|
||||
|
||||
|
||||
class ToolGateway(Protocol):
|
||||
def call(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
server_id: str,
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
task_context: dict[str, Any],
|
||||
) -> ToolInvocationResult: ...
|
||||
|
||||
|
||||
class WorkerGateway(Protocol):
|
||||
def dispatch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
worker_session_id: str,
|
||||
command_name: str,
|
||||
args: dict[str, Any],
|
||||
task_context: dict[str, Any],
|
||||
) -> ToolInvocationResult: ...
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Application services."""
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ai_orchestrator.application.ports import (
|
||||
ConfirmationRepository,
|
||||
EventStore,
|
||||
GraphRepository,
|
||||
PolicyEvaluator,
|
||||
TaskRepository,
|
||||
)
|
||||
from ai_orchestrator.domain.enums import NodeType, PolicyDecisionType
|
||||
from ai_orchestrator.domain.events import DomainEvent
|
||||
from ai_orchestrator.domain.models import (
|
||||
ActionDescriptor,
|
||||
ConfirmationRequest,
|
||||
ExecutionGraph,
|
||||
ExecutionNode,
|
||||
Task,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreateTaskRequest:
|
||||
project_id: str
|
||||
goal: str
|
||||
inputs: dict[str, Any]
|
||||
conversation_id: str | None = None
|
||||
requested_mode: str = "auto"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrchestratorService:
|
||||
task_repository: TaskRepository
|
||||
graph_repository: GraphRepository
|
||||
confirmation_repository: ConfirmationRepository
|
||||
event_store: EventStore
|
||||
policy_evaluator: PolicyEvaluator
|
||||
|
||||
def create_task(self, request: CreateTaskRequest) -> Task:
|
||||
task = Task(
|
||||
project_id=request.project_id,
|
||||
goal=request.goal,
|
||||
inputs=request.inputs,
|
||||
conversation_id=request.conversation_id,
|
||||
requested_mode=request.requested_mode,
|
||||
effective_mode=request.requested_mode,
|
||||
)
|
||||
self.task_repository.create(task)
|
||||
self.event_store.append(
|
||||
DomainEvent(
|
||||
event_type="task_created",
|
||||
task_id=task.task_id,
|
||||
conversation_id=task.conversation_id,
|
||||
payload={"goal": task.goal, "requested_mode": task.requested_mode},
|
||||
)
|
||||
)
|
||||
return task
|
||||
|
||||
def plan_task(self, task_id: str) -> ExecutionGraph:
|
||||
task = self._require_task(task_id)
|
||||
planner = ExecutionNode(
|
||||
task_id=task.task_id,
|
||||
node_type=NodeType.PLANNER,
|
||||
input_data={"goal": task.goal, "inputs": task.inputs},
|
||||
)
|
||||
finalizer = ExecutionNode(
|
||||
task_id=task.task_id,
|
||||
node_type=NodeType.FINALIZER,
|
||||
input_data={"task_id": task.task_id},
|
||||
dependencies=[planner.node_id],
|
||||
retryable=False,
|
||||
)
|
||||
graph = ExecutionGraph(task_id=task.task_id, nodes=[planner, finalizer])
|
||||
self.graph_repository.save(graph)
|
||||
task.mark_planned()
|
||||
self.task_repository.save(task)
|
||||
self.event_store.append(
|
||||
DomainEvent(
|
||||
event_type="task_planned",
|
||||
task_id=task.task_id,
|
||||
conversation_id=task.conversation_id,
|
||||
payload={"node_count": len(graph.nodes)},
|
||||
)
|
||||
)
|
||||
return graph
|
||||
|
||||
def evaluate_action(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
node_id: str,
|
||||
action: ActionDescriptor,
|
||||
) -> ConfirmationRequest | None:
|
||||
task = self._require_task(task_id)
|
||||
decision = self.policy_evaluator.evaluate(action, task.project_id)
|
||||
self.event_store.append(
|
||||
DomainEvent(
|
||||
event_type="policy_decision_made",
|
||||
task_id=task.task_id,
|
||||
conversation_id=task.conversation_id,
|
||||
node_id=node_id,
|
||||
payload={
|
||||
"action_name": action.action_name,
|
||||
"resource": action.resource,
|
||||
"risk_level": action.risk_level.value,
|
||||
"decision": decision.decision.value,
|
||||
},
|
||||
)
|
||||
)
|
||||
if decision.decision != PolicyDecisionType.CONFIRM:
|
||||
return None
|
||||
confirmation = ConfirmationRequest(task_id=task.task_id, node_id=node_id, decision=decision)
|
||||
self.confirmation_repository.create(confirmation)
|
||||
task.wait_for_confirmation(node_id=node_id)
|
||||
self.task_repository.save(task)
|
||||
self.event_store.append(
|
||||
DomainEvent(
|
||||
event_type="confirmation_requested",
|
||||
task_id=task.task_id,
|
||||
conversation_id=task.conversation_id,
|
||||
node_id=node_id,
|
||||
payload={"confirmation_id": confirmation.confirmation_id},
|
||||
)
|
||||
)
|
||||
return confirmation
|
||||
|
||||
def _require_task(self, task_id: str) -> Task:
|
||||
task = self.task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise LookupError(f"Task not found: {task_id}")
|
||||
return task
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ai_orchestrator.application.ports import EventStore, WorkerRepository
|
||||
from ai_orchestrator.domain.events import DomainEvent
|
||||
from ai_orchestrator.domain.models import WorkerSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RegisterWorkerRequest:
|
||||
worker_id: str
|
||||
name: str
|
||||
machine: str
|
||||
os: str
|
||||
version: str
|
||||
capabilities: list[str]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerService:
|
||||
worker_repository: WorkerRepository
|
||||
event_store: EventStore
|
||||
|
||||
def register(self, request: RegisterWorkerRequest) -> WorkerSession:
|
||||
worker = WorkerSession(
|
||||
worker_id=request.worker_id,
|
||||
name=request.name,
|
||||
machine=request.machine,
|
||||
os=request.os,
|
||||
version=request.version,
|
||||
capabilities=request.capabilities,
|
||||
)
|
||||
worker.mark_online()
|
||||
self.worker_repository.save(worker)
|
||||
self.event_store.append(
|
||||
DomainEvent(
|
||||
event_type="worker_registered",
|
||||
task_id="system",
|
||||
payload={
|
||||
"session_id": worker.session_id,
|
||||
"worker_id": worker.worker_id,
|
||||
"capabilities": worker.capabilities,
|
||||
},
|
||||
)
|
||||
)
|
||||
return worker
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ProviderType(StrEnum):
|
||||
LOCAL = "local"
|
||||
EXTERNAL = "external"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class PolicyMode(StrEnum):
|
||||
FULL_AUTO = "full_auto"
|
||||
CONFIRM = "confirm"
|
||||
MANUAL = "manual"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class ModelSlotConfig(BaseModel):
|
||||
provider: ProviderType
|
||||
model: str | None = None
|
||||
base_url: str | None = None
|
||||
api_key_env: str | None = None
|
||||
|
||||
|
||||
class ModelsConfig(BaseModel):
|
||||
weak: ModelSlotConfig
|
||||
strong: ModelSlotConfig
|
||||
vision: ModelSlotConfig
|
||||
embedding: ModelSlotConfig
|
||||
|
||||
|
||||
class ExecutionConfig(BaseModel):
|
||||
max_graph_nodes: int = 20
|
||||
max_agent_steps: int = 8
|
||||
max_local_retries: int = 2
|
||||
allow_external_models: bool = False
|
||||
allow_paid_fallback: bool = False
|
||||
|
||||
|
||||
class PolicyResourceConfig(BaseModel):
|
||||
filesystem: PolicyMode = PolicyMode.CONFIRM
|
||||
shell: PolicyMode = PolicyMode.CONFIRM
|
||||
sql: PolicyMode = PolicyMode.CONFIRM
|
||||
mcp: PolicyMode = PolicyMode.CONFIRM
|
||||
external_models: PolicyMode = PolicyMode.CONFIRM
|
||||
desktop: PolicyMode = PolicyMode.CONFIRM
|
||||
browser: PolicyMode = PolicyMode.CONFIRM
|
||||
network: PolicyMode = PolicyMode.CONFIRM
|
||||
cost: PolicyMode = PolicyMode.CONFIRM
|
||||
system: PolicyMode = PolicyMode.CONFIRM
|
||||
|
||||
|
||||
class PolicyConfig(BaseModel):
|
||||
default_mode: PolicyMode = PolicyMode.CONFIRM
|
||||
resources: PolicyResourceConfig = Field(default_factory=PolicyResourceConfig)
|
||||
|
||||
|
||||
class ProjectMetadata(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ProjectConfig(BaseModel):
|
||||
project: ProjectMetadata
|
||||
models: ModelsConfig | None = None
|
||||
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
||||
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="AI_ORCH_", extra="ignore")
|
||||
|
||||
app_name: str = "ai-orchestrator"
|
||||
env: str = "dev"
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8080
|
||||
default_project_config: str = "configs/examples/local_only.yaml"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Delivery layer."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""HTTP delivery adapters."""
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from ai_orchestrator.application.services.orchestrator import CreateTaskRequest, OrchestratorService
|
||||
from ai_orchestrator.application.services.workers import RegisterWorkerRequest, WorkerService
|
||||
from ai_orchestrator.config import AppSettings
|
||||
from ai_orchestrator.delivery.http.schemas import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
ConfirmationApproveRequest,
|
||||
ConfirmationRejectRequest,
|
||||
CreateTaskRequestSchema,
|
||||
TaskStatusResponse,
|
||||
WorkerListItem,
|
||||
WorkerListResponse,
|
||||
WorkerRegisterRequest,
|
||||
)
|
||||
from ai_orchestrator.infrastructure.config_loader import load_project_config
|
||||
from ai_orchestrator.infrastructure.policy import StaticProjectPolicyEvaluator
|
||||
from ai_orchestrator.infrastructure.storage.memory import (
|
||||
InMemoryConfirmationRepository,
|
||||
InMemoryEventStore,
|
||||
InMemoryGraphRepository,
|
||||
InMemoryTaskRepository,
|
||||
InMemoryWorkerRepository,
|
||||
)
|
||||
|
||||
|
||||
def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
settings = settings or AppSettings()
|
||||
project_config = load_project_config(settings.default_project_config)
|
||||
task_repository = InMemoryTaskRepository()
|
||||
graph_repository = InMemoryGraphRepository()
|
||||
confirmation_repository = InMemoryConfirmationRepository()
|
||||
worker_repository = InMemoryWorkerRepository()
|
||||
event_store = InMemoryEventStore()
|
||||
policy_evaluator = StaticProjectPolicyEvaluator(
|
||||
projects={project_config.project.id: project_config}
|
||||
)
|
||||
orchestrator = OrchestratorService(
|
||||
task_repository=task_repository,
|
||||
graph_repository=graph_repository,
|
||||
confirmation_repository=confirmation_repository,
|
||||
event_store=event_store,
|
||||
policy_evaluator=policy_evaluator,
|
||||
)
|
||||
|
||||
app = FastAPI(title="AI Orchestrator", version="0.1.0")
|
||||
app.state.task_repository = task_repository
|
||||
app.state.graph_repository = graph_repository
|
||||
app.state.confirmation_repository = confirmation_repository
|
||||
app.state.worker_repository = worker_repository
|
||||
app.state.event_store = event_store
|
||||
app.state.orchestrator = orchestrator
|
||||
app.state.worker_service = WorkerService(
|
||||
worker_repository=worker_repository,
|
||||
event_store=event_store,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
def post_chat(payload: ChatRequest) -> ChatResponse:
|
||||
task = orchestrator.create_task(
|
||||
CreateTaskRequest(
|
||||
project_id=payload.project_id,
|
||||
goal=payload.message,
|
||||
inputs={"attachments": payload.attachments, "preferences": payload.preferences},
|
||||
conversation_id=payload.conversation_id or "conv_default",
|
||||
requested_mode=payload.mode,
|
||||
)
|
||||
)
|
||||
orchestrator.plan_task(task.task_id)
|
||||
return ChatResponse(
|
||||
conversation_id=task.conversation_id or "conv_default",
|
||||
task_id=task.task_id,
|
||||
response_type="task_started",
|
||||
message="Task accepted and planned.",
|
||||
cards=[{"type": "plan_card", "task_id": task.task_id}],
|
||||
)
|
||||
|
||||
@app.post("/tasks", response_model=TaskStatusResponse)
|
||||
def post_tasks(payload: CreateTaskRequestSchema) -> TaskStatusResponse:
|
||||
task = orchestrator.create_task(
|
||||
CreateTaskRequest(
|
||||
project_id=payload.project_id,
|
||||
goal=payload.goal,
|
||||
inputs=payload.inputs,
|
||||
requested_mode=payload.execution_mode,
|
||||
)
|
||||
)
|
||||
graph = orchestrator.plan_task(task.task_id)
|
||||
return TaskStatusResponse(
|
||||
task_id=task.task_id,
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": 0, "total": len(graph.nodes)},
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_model=TaskStatusResponse)
|
||||
def get_task(task_id: str) -> TaskStatusResponse:
|
||||
task = task_repository.get(task_id)
|
||||
graph = graph_repository.get(task_id)
|
||||
if task is None or graph is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
completed = sum(1 for node in graph.nodes if node.status.value == "completed")
|
||||
return TaskStatusResponse(
|
||||
task_id=task.task_id,
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": completed, "total": len(graph.nodes)},
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}/events")
|
||||
async def get_task_events(task_id: str) -> EventSourceResponse:
|
||||
events = event_store.list_by_task(task_id)
|
||||
|
||||
async def iterator():
|
||||
for event in events:
|
||||
yield {
|
||||
"event": event.event_type,
|
||||
"data": {
|
||||
"event_id": event.event_id,
|
||||
"task_id": event.task_id,
|
||||
"node_id": event.node_id,
|
||||
"timestamp": event.occurred_at.isoformat(),
|
||||
"payload": event.payload,
|
||||
},
|
||||
}
|
||||
|
||||
return EventSourceResponse(iterator())
|
||||
|
||||
@app.post("/confirmations/{confirmation_id}/approve")
|
||||
def approve_confirmation(
|
||||
confirmation_id: str,
|
||||
payload: ConfirmationApproveRequest,
|
||||
) -> dict[str, str]:
|
||||
confirmation = confirmation_repository.get(confirmation_id)
|
||||
if confirmation is None:
|
||||
raise HTTPException(status_code=404, detail="Confirmation not found")
|
||||
confirmation.approve(payload.comment)
|
||||
confirmation_repository.save(confirmation)
|
||||
return {"status": confirmation.status.value}
|
||||
|
||||
@app.post("/confirmations/{confirmation_id}/reject")
|
||||
def reject_confirmation(
|
||||
confirmation_id: str,
|
||||
payload: ConfirmationRejectRequest,
|
||||
) -> dict[str, str]:
|
||||
confirmation = confirmation_repository.get(confirmation_id)
|
||||
if confirmation is None:
|
||||
raise HTTPException(status_code=404, detail="Confirmation not found")
|
||||
confirmation.reject(payload.reason)
|
||||
confirmation_repository.save(confirmation)
|
||||
return {"status": confirmation.status.value}
|
||||
|
||||
@app.get("/workers", response_model=WorkerListResponse)
|
||||
def list_workers() -> WorkerListResponse:
|
||||
return WorkerListResponse(
|
||||
workers=[
|
||||
WorkerListItem(
|
||||
session_id=worker.session_id,
|
||||
worker_id=worker.worker_id,
|
||||
name=worker.name,
|
||||
status=worker.status.value,
|
||||
capabilities=worker.capabilities,
|
||||
)
|
||||
for worker in worker_repository.list_active()
|
||||
]
|
||||
)
|
||||
|
||||
@app.post("/workers/register")
|
||||
def register_worker(payload: WorkerRegisterRequest) -> dict[str, object]:
|
||||
worker = app.state.worker_service.register(
|
||||
RegisterWorkerRequest(
|
||||
worker_id=payload.worker_id,
|
||||
name=payload.name,
|
||||
machine=payload.machine or payload.name,
|
||||
os=payload.os or "unknown",
|
||||
version=payload.version,
|
||||
capabilities=payload.capabilities,
|
||||
)
|
||||
)
|
||||
return {"session_id": worker.session_id, "status": worker.status.value}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
project_id: str = "default"
|
||||
conversation_id: str | None = None
|
||||
message: str
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
mode: str = "auto"
|
||||
preferences: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
conversation_id: str
|
||||
task_id: str
|
||||
response_type: str
|
||||
message: str
|
||||
cards: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateTaskRequestSchema(BaseModel):
|
||||
project_id: str
|
||||
goal: str
|
||||
inputs: dict[str, Any] = Field(default_factory=dict)
|
||||
execution_mode: str = "agent_graph"
|
||||
|
||||
|
||||
class TaskStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
current_node: str | None
|
||||
progress: dict[str, int]
|
||||
|
||||
|
||||
class ConfirmationApproveRequest(BaseModel):
|
||||
scope: str = "once"
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ConfirmationRejectRequest(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class WorkerRegisterRequest(BaseModel):
|
||||
worker_id: str
|
||||
name: str
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
version: str
|
||||
machine: str | None = None
|
||||
os: str | None = None
|
||||
|
||||
|
||||
class WorkerListItem(BaseModel):
|
||||
session_id: str
|
||||
worker_id: str
|
||||
name: str
|
||||
status: str
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkerListResponse(BaseModel):
|
||||
workers: list[WorkerListItem] = Field(default_factory=list)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Domain layer."""
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TaskStatus(StrEnum):
|
||||
CREATED = "created"
|
||||
PLANNED = "planned"
|
||||
RUNNING = "running"
|
||||
WAITING_CONFIRMATION = "waiting_confirmation"
|
||||
WAITING_MANUAL = "waiting_manual"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class NodeStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
READY = "ready"
|
||||
RUNNING = "running"
|
||||
WAITING_CONFIRMATION = "waiting_confirmation"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class NodeType(StrEnum):
|
||||
PLANNER = "planner"
|
||||
MODEL_CALL = "model_call"
|
||||
TOOL_CALL = "tool_call"
|
||||
LOCAL_WORKER_CALL = "local_worker_call"
|
||||
REVIEWER = "reviewer"
|
||||
FINALIZER = "finalizer"
|
||||
CONFIRMATION = "confirmation"
|
||||
|
||||
|
||||
class ConfirmationStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class WorkerSessionStatus(StrEnum):
|
||||
CONNECTING = "connecting"
|
||||
ONLINE = "online"
|
||||
BUSY = "busy"
|
||||
STALE = "stale"
|
||||
DISCONNECTED = "disconnected"
|
||||
|
||||
|
||||
class ResourceType(StrEnum):
|
||||
FILESYSTEM = "filesystem"
|
||||
SHELL = "shell"
|
||||
SQL = "sql"
|
||||
MCP = "mcp"
|
||||
EXTERNAL_MODELS = "external_models"
|
||||
DESKTOP = "desktop"
|
||||
BROWSER = "browser"
|
||||
NETWORK = "network"
|
||||
COST = "cost"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
SAFE = "safe"
|
||||
WRITE = "write"
|
||||
DESTRUCTIVE = "destructive"
|
||||
SYSTEM = "system"
|
||||
COST = "cost"
|
||||
|
||||
|
||||
class PolicyDecisionType(StrEnum):
|
||||
ALLOW = "allow"
|
||||
CONFIRM = "confirm"
|
||||
MANUAL = "manual"
|
||||
DISABLED_BY_CONFIG = "disabled_by_config"
|
||||
|
||||
|
||||
class ConfirmationScope(StrEnum):
|
||||
ONCE = "once"
|
||||
TASK = "task"
|
||||
PROJECT = "project"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class OrchestratorError(Exception):
|
||||
"""Base class for orchestrator errors."""
|
||||
|
||||
|
||||
class ValidationError(OrchestratorError):
|
||||
"""Raised when request or data validation fails."""
|
||||
|
||||
|
||||
class PolicyError(OrchestratorError):
|
||||
"""Raised when policy blocks or pauses execution."""
|
||||
|
||||
|
||||
class RetryableInfrastructureError(OrchestratorError):
|
||||
"""Raised for transient upstream or transport errors."""
|
||||
|
||||
|
||||
class NonRetryableInfrastructureError(OrchestratorError):
|
||||
"""Raised for permanent upstream or integration errors."""
|
||||
|
||||
|
||||
class ExecutionError(OrchestratorError):
|
||||
"""Raised when node execution fails semantically."""
|
||||
|
||||
|
||||
class CancellationError(OrchestratorError):
|
||||
"""Raised when task or node execution was cancelled."""
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DomainEvent:
|
||||
event_type: str
|
||||
task_id: str
|
||||
payload: dict[str, Any]
|
||||
conversation_id: str | None = None
|
||||
node_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
event_id: str = field(default_factory=lambda: f"evt_{uuid4().hex}")
|
||||
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ai_orchestrator.domain.enums import (
|
||||
ConfirmationScope,
|
||||
ConfirmationStatus,
|
||||
NodeStatus,
|
||||
NodeType,
|
||||
PolicyDecisionType,
|
||||
RiskLevel,
|
||||
TaskStatus,
|
||||
WorkerSessionStatus,
|
||||
)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Task:
|
||||
project_id: str
|
||||
goal: str
|
||||
inputs: dict[str, Any]
|
||||
conversation_id: str | None = None
|
||||
requested_mode: str = "auto"
|
||||
effective_mode: str = "auto"
|
||||
task_id: str = field(default_factory=lambda: f"task_{uuid4().hex}")
|
||||
status: TaskStatus = TaskStatus.CREATED
|
||||
current_node_id: str | None = None
|
||||
result_summary: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=_utcnow)
|
||||
updated_at: datetime = field(default_factory=_utcnow)
|
||||
|
||||
def mark_planned(self) -> None:
|
||||
self.status = TaskStatus.PLANNED
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def mark_running(self, node_id: str | None = None) -> None:
|
||||
self.status = TaskStatus.RUNNING
|
||||
self.current_node_id = node_id
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def wait_for_confirmation(self, node_id: str | None = None) -> None:
|
||||
self.status = TaskStatus.WAITING_CONFIRMATION
|
||||
self.current_node_id = node_id
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def wait_for_manual(self, node_id: str | None = None) -> None:
|
||||
self.status = TaskStatus.WAITING_MANUAL
|
||||
self.current_node_id = node_id
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def complete(self, summary: dict[str, Any]) -> None:
|
||||
self.status = TaskStatus.COMPLETED
|
||||
self.result_summary = summary
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def fail(self, summary: dict[str, Any]) -> None:
|
||||
self.status = TaskStatus.FAILED
|
||||
self.result_summary = summary
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExecutionNode:
|
||||
task_id: str
|
||||
node_type: NodeType
|
||||
input_data: dict[str, Any]
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
node_id: str = field(default_factory=lambda: f"node_{uuid4().hex}")
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
output_data: dict[str, Any] = field(default_factory=dict)
|
||||
assigned_runner: str | None = None
|
||||
attempts: int = 0
|
||||
retryable: bool = True
|
||||
timeout_ms: int | None = None
|
||||
created_at: datetime = field(default_factory=_utcnow)
|
||||
updated_at: datetime = field(default_factory=_utcnow)
|
||||
|
||||
def is_ready(self, completed_node_ids: set[str]) -> bool:
|
||||
return all(dep in completed_node_ids for dep in self.dependencies)
|
||||
|
||||
def mark_ready(self) -> None:
|
||||
self.status = NodeStatus.READY
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def mark_running(self) -> None:
|
||||
self.status = NodeStatus.RUNNING
|
||||
self.attempts += 1
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def mark_waiting_confirmation(self) -> None:
|
||||
self.status = NodeStatus.WAITING_CONFIRMATION
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def mark_completed(self, output_data: dict[str, Any]) -> None:
|
||||
self.status = NodeStatus.COMPLETED
|
||||
self.output_data = output_data
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
def mark_failed(self, output_data: dict[str, Any] | None = None) -> None:
|
||||
self.status = NodeStatus.FAILED
|
||||
self.output_data = output_data or {}
|
||||
self.updated_at = _utcnow()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExecutionGraph:
|
||||
task_id: str
|
||||
nodes: list[ExecutionNode]
|
||||
|
||||
def completed_node_ids(self) -> set[str]:
|
||||
return {node.node_id for node in self.nodes if node.status == NodeStatus.COMPLETED}
|
||||
|
||||
def ready_nodes(self) -> list[ExecutionNode]:
|
||||
completed = self.completed_node_ids()
|
||||
ready: list[ExecutionNode] = []
|
||||
for node in self.nodes:
|
||||
if node.status == NodeStatus.PENDING and node.is_ready(completed):
|
||||
node.mark_ready()
|
||||
if node.status == NodeStatus.READY:
|
||||
ready.append(node)
|
||||
return ready
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActionDescriptor:
|
||||
resource: str
|
||||
risk_level: RiskLevel
|
||||
action_name: str
|
||||
preview_available: bool = False
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PolicyDecision:
|
||||
decision: PolicyDecisionType
|
||||
reason: str
|
||||
requires_confirmation: bool = False
|
||||
preview: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConfirmationRequest:
|
||||
task_id: str
|
||||
node_id: str
|
||||
decision: PolicyDecision
|
||||
scope: ConfirmationScope = ConfirmationScope.ONCE
|
||||
confirmation_id: str = field(default_factory=lambda: f"conf_{uuid4().hex}")
|
||||
status: ConfirmationStatus = ConfirmationStatus.PENDING
|
||||
comment: str | None = None
|
||||
created_at: datetime = field(default_factory=_utcnow)
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
def approve(self, comment: str | None = None) -> None:
|
||||
self.status = ConfirmationStatus.APPROVED
|
||||
self.comment = comment
|
||||
self.resolved_at = _utcnow()
|
||||
|
||||
def reject(self, comment: str | None = None) -> None:
|
||||
self.status = ConfirmationStatus.REJECTED
|
||||
self.comment = comment
|
||||
self.resolved_at = _utcnow()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerSession:
|
||||
worker_id: str
|
||||
name: str
|
||||
machine: str
|
||||
os: str
|
||||
version: str
|
||||
capabilities: list[str]
|
||||
session_id: str = field(default_factory=lambda: f"wrk_{uuid4().hex}")
|
||||
status: WorkerSessionStatus = WorkerSessionStatus.CONNECTING
|
||||
current_task_id: str | None = None
|
||||
last_heartbeat_at: datetime = field(default_factory=_utcnow)
|
||||
connected_at: datetime = field(default_factory=_utcnow)
|
||||
disconnected_at: datetime | None = None
|
||||
|
||||
def mark_online(self) -> None:
|
||||
self.status = WorkerSessionStatus.ONLINE
|
||||
self.last_heartbeat_at = _utcnow()
|
||||
|
||||
def heartbeat(self) -> None:
|
||||
self.last_heartbeat_at = _utcnow()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Infrastructure layer."""
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ai_orchestrator.config import ProjectConfig
|
||||
|
||||
|
||||
def load_project_config(path: str | Path) -> ProjectConfig:
|
||||
config_path = Path(path)
|
||||
with config_path.open("r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
return ProjectConfig.model_validate(raw)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
|
||||
|
||||
def log_structured(logger: logging.Logger, event_type: str, **payload: Any) -> None:
|
||||
logger.info(json.dumps({"event_type": event_type, **payload}, ensure_ascii=True, default=str))
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ai_orchestrator.application.ports import PolicyEvaluator
|
||||
from ai_orchestrator.config import PolicyMode, ProjectConfig
|
||||
from ai_orchestrator.domain.enums import PolicyDecisionType
|
||||
from ai_orchestrator.domain.models import ActionDescriptor, PolicyDecision
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StaticProjectPolicyEvaluator(PolicyEvaluator):
|
||||
projects: dict[str, ProjectConfig]
|
||||
|
||||
def evaluate(self, action: ActionDescriptor, project_id: str) -> PolicyDecision:
|
||||
project = self.projects[project_id]
|
||||
resource_mode = getattr(
|
||||
project.policy.resources,
|
||||
action.resource,
|
||||
project.policy.default_mode,
|
||||
)
|
||||
mapping = {
|
||||
PolicyMode.FULL_AUTO: PolicyDecisionType.ALLOW,
|
||||
PolicyMode.CONFIRM: PolicyDecisionType.CONFIRM,
|
||||
PolicyMode.MANUAL: PolicyDecisionType.MANUAL,
|
||||
PolicyMode.DISABLED: PolicyDecisionType.DISABLED_BY_CONFIG,
|
||||
}
|
||||
decision = mapping[resource_mode]
|
||||
return PolicyDecision(
|
||||
decision=decision,
|
||||
reason=f"Policy mode for {action.resource}: {resource_mode.value}",
|
||||
requires_confirmation=decision == PolicyDecisionType.CONFIRM,
|
||||
preview={"preview_available": action.preview_available, **action.metadata},
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Storage adapters."""
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ai_orchestrator.application.ports import (
|
||||
ConfirmationRepository,
|
||||
EventStore,
|
||||
GraphRepository,
|
||||
TaskRepository,
|
||||
WorkerRepository,
|
||||
)
|
||||
from ai_orchestrator.domain.events import DomainEvent
|
||||
from ai_orchestrator.domain.models import ConfirmationRequest, ExecutionGraph, Task, WorkerSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InMemoryTaskRepository(TaskRepository):
|
||||
items: dict[str, Task] = field(default_factory=dict)
|
||||
|
||||
def create(self, task: Task) -> Task:
|
||||
self.items[task.task_id] = task
|
||||
return task
|
||||
|
||||
def save(self, task: Task) -> Task:
|
||||
self.items[task.task_id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> Task | None:
|
||||
return self.items.get(task_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InMemoryGraphRepository(GraphRepository):
|
||||
items: dict[str, ExecutionGraph] = field(default_factory=dict)
|
||||
|
||||
def save(self, graph: ExecutionGraph) -> ExecutionGraph:
|
||||
self.items[graph.task_id] = graph
|
||||
return graph
|
||||
|
||||
def get(self, task_id: str) -> ExecutionGraph | None:
|
||||
return self.items.get(task_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InMemoryConfirmationRepository(ConfirmationRepository):
|
||||
items: dict[str, ConfirmationRequest] = field(default_factory=dict)
|
||||
|
||||
def create(self, confirmation: ConfirmationRequest) -> ConfirmationRequest:
|
||||
self.items[confirmation.confirmation_id] = confirmation
|
||||
return confirmation
|
||||
|
||||
def get(self, confirmation_id: str) -> ConfirmationRequest | None:
|
||||
return self.items.get(confirmation_id)
|
||||
|
||||
def save(self, confirmation: ConfirmationRequest) -> ConfirmationRequest:
|
||||
self.items[confirmation.confirmation_id] = confirmation
|
||||
return confirmation
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InMemoryWorkerRepository(WorkerRepository):
|
||||
items: dict[str, WorkerSession] = field(default_factory=dict)
|
||||
|
||||
def save(self, worker: WorkerSession) -> WorkerSession:
|
||||
self.items[worker.session_id] = worker
|
||||
return worker
|
||||
|
||||
def get(self, session_id: str) -> WorkerSession | None:
|
||||
return self.items.get(session_id)
|
||||
|
||||
def list_active(self) -> list[WorkerSession]:
|
||||
return list(self.items.values())
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InMemoryEventStore(EventStore):
|
||||
items: list[DomainEvent] = field(default_factory=list)
|
||||
|
||||
def append(self, event: DomainEvent) -> DomainEvent:
|
||||
self.items.append(event)
|
||||
return event
|
||||
|
||||
def list_by_task(self, task_id: str) -> list[DomainEvent]:
|
||||
return [event for event in self.items if event.task_id == task_id]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ai_orchestrator.delivery.http.app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_orchestrator.main import app
|
||||
|
||||
|
||||
def test_health_endpoint_returns_ok() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_post_tasks_creates_planned_task() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/tasks",
|
||||
json={
|
||||
"project_id": "default",
|
||||
"goal": "Prepare a plan",
|
||||
"inputs": {"source": "test"},
|
||||
"execution_mode": "agent_graph",
|
||||
},
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload["status"] == "planned"
|
||||
assert payload["progress"]["total"] == 2
|
||||
|
||||
|
||||
def test_worker_registration_is_exposed_via_list_endpoint() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
registration = client.post(
|
||||
"/workers/register",
|
||||
json={
|
||||
"worker_id": "worker_home_pc",
|
||||
"name": "Home PC",
|
||||
"capabilities": ["file.read", "command.run"],
|
||||
"version": "0.1.0",
|
||||
"machine": "DESKTOP-1",
|
||||
"os": "windows",
|
||||
},
|
||||
)
|
||||
listing = client.get("/workers")
|
||||
|
||||
assert registration.status_code == 200
|
||||
assert listing.status_code == 200
|
||||
assert any(worker["worker_id"] == "worker_home_pc" for worker in listing.json()["workers"])
|
||||
@@ -0,0 +1,21 @@
|
||||
from ai_orchestrator.domain.enums import NodeStatus, NodeType
|
||||
from ai_orchestrator.domain.models import ExecutionGraph, ExecutionNode
|
||||
|
||||
|
||||
def test_graph_marks_pending_node_ready_when_dependencies_are_completed() -> None:
|
||||
first = ExecutionNode(task_id="task_1", node_type=NodeType.PLANNER, input_data={})
|
||||
second = ExecutionNode(
|
||||
task_id="task_1",
|
||||
node_type=NodeType.FINALIZER,
|
||||
input_data={},
|
||||
dependencies=[first.node_id],
|
||||
)
|
||||
first.mark_completed({"ok": True})
|
||||
graph = ExecutionGraph(task_id="task_1", nodes=[first, second])
|
||||
|
||||
ready = graph.ready_nodes()
|
||||
|
||||
assert len(ready) == 1
|
||||
assert ready[0].node_id == second.node_id
|
||||
assert second.status == NodeStatus.READY
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from ai_orchestrator.config import (
|
||||
ExecutionConfig,
|
||||
ModelsConfig,
|
||||
ModelSlotConfig,
|
||||
PolicyConfig,
|
||||
PolicyMode,
|
||||
ProjectConfig,
|
||||
ProjectMetadata,
|
||||
)
|
||||
from ai_orchestrator.domain.enums import PolicyDecisionType, RiskLevel
|
||||
from ai_orchestrator.domain.models import ActionDescriptor
|
||||
from ai_orchestrator.infrastructure.policy import StaticProjectPolicyEvaluator
|
||||
|
||||
|
||||
def test_policy_evaluator_returns_confirm_for_configured_resource() -> None:
|
||||
project = ProjectConfig(
|
||||
project=ProjectMetadata(id="default", name="Default"),
|
||||
models=ModelsConfig(
|
||||
weak=ModelSlotConfig(provider="local", model="weak"),
|
||||
strong=ModelSlotConfig(provider="local", model="strong"),
|
||||
vision=ModelSlotConfig(provider="disabled"),
|
||||
embedding=ModelSlotConfig(provider="local", model="embed"),
|
||||
),
|
||||
execution=ExecutionConfig(),
|
||||
policy=PolicyConfig(),
|
||||
)
|
||||
evaluator = StaticProjectPolicyEvaluator(projects={"default": project})
|
||||
|
||||
decision = evaluator.evaluate(
|
||||
ActionDescriptor(
|
||||
resource="filesystem",
|
||||
risk_level=RiskLevel.WRITE,
|
||||
action_name="file.write",
|
||||
preview_available=True,
|
||||
),
|
||||
"default",
|
||||
)
|
||||
|
||||
assert decision.decision == PolicyDecisionType.CONFIRM
|
||||
assert decision.requires_confirmation is True
|
||||
|
||||
|
||||
def test_policy_evaluator_returns_disabled_by_config() -> None:
|
||||
project = ProjectConfig(
|
||||
project=ProjectMetadata(id="default", name="Default"),
|
||||
models=ModelsConfig(
|
||||
weak=ModelSlotConfig(provider="local", model="weak"),
|
||||
strong=ModelSlotConfig(provider="local", model="strong"),
|
||||
vision=ModelSlotConfig(provider="disabled"),
|
||||
embedding=ModelSlotConfig(provider="local", model="embed"),
|
||||
),
|
||||
execution=ExecutionConfig(),
|
||||
policy=PolicyConfig(
|
||||
default_mode=PolicyMode.CONFIRM,
|
||||
resources={
|
||||
"filesystem": PolicyMode.CONFIRM,
|
||||
"shell": PolicyMode.CONFIRM,
|
||||
"sql": PolicyMode.CONFIRM,
|
||||
"mcp": PolicyMode.CONFIRM,
|
||||
"external_models": PolicyMode.DISABLED,
|
||||
"desktop": PolicyMode.CONFIRM,
|
||||
"browser": PolicyMode.CONFIRM,
|
||||
"network": PolicyMode.CONFIRM,
|
||||
"cost": PolicyMode.CONFIRM,
|
||||
"system": PolicyMode.CONFIRM,
|
||||
},
|
||||
),
|
||||
)
|
||||
evaluator = StaticProjectPolicyEvaluator(projects={"default": project})
|
||||
|
||||
decision = evaluator.evaluate(
|
||||
ActionDescriptor(
|
||||
resource="external_models",
|
||||
risk_level=RiskLevel.COST,
|
||||
action_name="model.external",
|
||||
),
|
||||
"default",
|
||||
)
|
||||
|
||||
assert decision.decision == PolicyDecisionType.DISABLED_BY_CONFIG
|
||||
@@ -0,0 +1,15 @@
|
||||
from ai_orchestrator.domain.enums import TaskStatus
|
||||
from ai_orchestrator.domain.models import Task
|
||||
|
||||
|
||||
def test_task_status_transitions() -> None:
|
||||
task = Task(project_id="default", goal="Test", inputs={})
|
||||
|
||||
task.mark_planned()
|
||||
task.mark_running()
|
||||
task.wait_for_confirmation("node_1")
|
||||
task.complete({"result": "ok"})
|
||||
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert task.current_node_id == "node_1"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from ai_orchestrator.application.services.workers import RegisterWorkerRequest, WorkerService
|
||||
from ai_orchestrator.infrastructure.storage.memory import (
|
||||
InMemoryEventStore,
|
||||
InMemoryWorkerRepository,
|
||||
)
|
||||
|
||||
|
||||
def test_worker_registration_creates_online_session_and_event() -> None:
|
||||
worker_repository = InMemoryWorkerRepository()
|
||||
event_store = InMemoryEventStore()
|
||||
service = WorkerService(worker_repository=worker_repository, event_store=event_store)
|
||||
|
||||
worker = service.register(
|
||||
RegisterWorkerRequest(
|
||||
worker_id="worker_home_pc",
|
||||
name="Home PC",
|
||||
machine="DESKTOP-1",
|
||||
os="windows",
|
||||
version="0.1.0",
|
||||
capabilities=["file.read", "command.run"],
|
||||
)
|
||||
)
|
||||
|
||||
assert worker.status.value == "online"
|
||||
assert worker_repository.get(worker.session_id) is not None
|
||||
assert event_store.items[-1].event_type == "worker_registered"
|
||||
Reference in New Issue
Block a user