Add architecture package and application skeleton
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user