-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement task assignment subsystem with pluggable strategies #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -358,6 +358,41 @@ class GracefulShutdownConfig(BaseModel): | |||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| class TaskAssignmentConfig(BaseModel): | ||||||||||||||||||||||||
| """Configuration for task assignment behaviour. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Attributes: | ||||||||||||||||||||||||
| strategy: Assignment strategy name (e.g. ``"role_based"``). | ||||||||||||||||||||||||
| min_score: Minimum capability score for agent eligibility. | ||||||||||||||||||||||||
| max_concurrent_tasks_per_agent: Maximum tasks an agent can | ||||||||||||||||||||||||
| handle concurrently. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| model_config = ConfigDict(frozen=True, allow_inf_nan=False) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| strategy: NotBlankStr = Field( | ||||||||||||||||||||||||
| default="role_based", | ||||||||||||||||||||||||
| description="Assignment strategy name", | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| min_score: float = Field( | ||||||||||||||||||||||||
| default=0.1, | ||||||||||||||||||||||||
| ge=0.0, | ||||||||||||||||||||||||
| le=1.0, | ||||||||||||||||||||||||
| description="Minimum capability score for agent eligibility", | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| max_concurrent_tasks_per_agent: int = Field( | ||||||||||||||||||||||||
| default=5, | ||||||||||||||||||||||||
| ge=1, | ||||||||||||||||||||||||
| le=50, | ||||||||||||||||||||||||
| description=( | ||||||||||||||||||||||||
| "Maximum concurrent tasks per agent. " | ||||||||||||||||||||||||
| "Reserved for engine-layer enforcement — the engine " | ||||||||||||||||||||||||
| "should pass this value to AssignmentRequest when " | ||||||||||||||||||||||||
| "wiring up the assignment pipeline." | ||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
||||||||||||||||||||||||
| "Maximum concurrent tasks per agent. " | |
| "Reserved for engine-layer enforcement — the engine " | |
| "should pass this value to AssignmentRequest when " | |
| "wiring up the assignment pipeline." | |
| ), | |
| ) | |
| "Maximum concurrent tasks an agent is intended to handle. " | |
| "Actual enforcement must be implemented by the task " | |
| "assignment/engine layer." | |
| ), | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """Task assignment engine. | ||
|
|
||
| Assigns tasks to agents using pluggable strategies: manual | ||
| designation, role-based scoring, or load-balanced selection. | ||
| """ | ||
|
|
||
| from ai_company.engine.assignment.models import ( | ||
| AgentWorkload, | ||
| AssignmentCandidate, | ||
| AssignmentRequest, | ||
| AssignmentResult, | ||
| ) | ||
| from ai_company.engine.assignment.protocol import TaskAssignmentStrategy | ||
| from ai_company.engine.assignment.service import TaskAssignmentService | ||
| from ai_company.engine.assignment.strategies import ( | ||
| STRATEGY_MAP, | ||
| STRATEGY_NAME_LOAD_BALANCED, | ||
| STRATEGY_NAME_MANUAL, | ||
| STRATEGY_NAME_ROLE_BASED, | ||
| LoadBalancedAssignmentStrategy, | ||
| ManualAssignmentStrategy, | ||
| RoleBasedAssignmentStrategy, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "STRATEGY_MAP", | ||
| "STRATEGY_NAME_LOAD_BALANCED", | ||
| "STRATEGY_NAME_MANUAL", | ||
| "STRATEGY_NAME_ROLE_BASED", | ||
| "AgentWorkload", | ||
| "AssignmentCandidate", | ||
| "AssignmentRequest", | ||
| "AssignmentResult", | ||
| "LoadBalancedAssignmentStrategy", | ||
| "ManualAssignmentStrategy", | ||
| "RoleBasedAssignmentStrategy", | ||
| "TaskAssignmentService", | ||
| "TaskAssignmentStrategy", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| """Task assignment domain models. | ||
|
|
||
| Frozen Pydantic models for assignment requests, results, | ||
| agent workloads, and assignment candidates. | ||
| """ | ||
|
|
||
| from typing import Self | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, model_validator | ||
|
|
||
| from ai_company.core.agent import AgentIdentity # noqa: TC001 | ||
| from ai_company.core.task import Task # noqa: TC001 | ||
| from ai_company.core.types import NotBlankStr # noqa: TC001 | ||
|
|
||
|
|
||
| class AgentWorkload(BaseModel): | ||
| """Snapshot of an agent's current workload. | ||
|
|
||
| Attributes: | ||
| agent_id: Unique agent identifier. | ||
| active_task_count: Number of tasks currently in progress. | ||
| total_cost_usd: Total cost incurred by this agent in USD. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(frozen=True, allow_inf_nan=False) | ||
|
|
||
| agent_id: NotBlankStr = Field(description="Agent identifier") | ||
| active_task_count: int = Field( | ||
| ge=0, | ||
| description="Number of tasks currently in progress", | ||
| ) | ||
| total_cost_usd: float = Field( | ||
| default=0.0, | ||
| ge=0.0, | ||
| description="Total cost incurred by this agent in USD", | ||
| ) | ||
|
|
||
|
|
||
| class AssignmentCandidate(BaseModel): | ||
| """A candidate agent for task assignment with scoring details. | ||
|
|
||
| Attributes: | ||
| agent_identity: The candidate agent. | ||
| score: Match score between 0.0 and 1.0. | ||
| matched_skills: Skills that matched the assignment requirements. | ||
| reason: Human-readable explanation of the score. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(frozen=True) | ||
|
|
||
| agent_identity: AgentIdentity = Field(description="Candidate agent") | ||
| score: float = Field( | ||
| ge=0.0, | ||
| le=1.0, | ||
| description="Match score (0.0-1.0)", | ||
| ) | ||
|
||
| matched_skills: tuple[NotBlankStr, ...] = Field( | ||
| default=(), | ||
| description="Skills that matched assignment requirements", | ||
| ) | ||
| reason: NotBlankStr = Field(description="Explanation of score") | ||
|
|
||
|
|
||
| class AssignmentRequest(BaseModel): | ||
| """Request for task assignment to an agent. | ||
|
|
||
| The ``required_skills`` and ``required_role`` fields live here | ||
| (not on Task) so that scoring strategies can evaluate agent-task | ||
| fit without modifying the Task model. | ||
|
|
||
| Attributes: | ||
| task: The task to assign. | ||
| available_agents: Pool of agents to consider (must be non-empty). | ||
| workloads: Current workload snapshots per agent. | ||
| min_score: Minimum score threshold for eligibility. | ||
| required_skills: Skill names needed for scoring. | ||
| required_role: Optional role name for scoring. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(frozen=True) | ||
|
|
||
| task: Task = Field(description="The task to assign") | ||
| available_agents: tuple[AgentIdentity, ...] = Field( | ||
| description="Pool of agents to consider", | ||
| ) | ||
| workloads: tuple[AgentWorkload, ...] = Field( | ||
| default=(), | ||
| description="Current workload snapshots per agent", | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| min_score: float = Field( | ||
| default=0.1, | ||
| ge=0.0, | ||
| le=1.0, | ||
| description="Minimum score threshold for eligibility", | ||
| ) | ||
|
||
| required_skills: tuple[NotBlankStr, ...] = Field( | ||
| default=(), | ||
| description="Skill names needed for scoring", | ||
| ) | ||
| required_role: NotBlankStr | None = Field( | ||
| default=None, | ||
| description="Optional role name for scoring", | ||
| ) | ||
|
|
||
| @model_validator(mode="after") | ||
| def _validate_non_empty_agents(self) -> Self: | ||
| """Ensure at least one agent is available for assignment.""" | ||
| if not self.available_agents: | ||
| msg = "available_agents must not be empty" | ||
| raise ValueError(msg) | ||
| return self | ||
|
Comment on lines
+108
to
+128
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The I suggest combining the existing @model_validator(mode="after")
def _validate_request_collections(self) -> Self:
"""Ensure collections in the request are valid.
- `available_agents` must not be empty.
- Agent IDs in `available_agents` must be unique.
- Agent IDs in `workloads` must be unique.
"""
if not self.available_agents:
msg = "available_agents must not be empty"
raise ValueError(msg)
agent_ids = [a.id for a in self.available_agents]
if len(agent_ids) != len(set(agent_ids)):
from collections import Counter
dupes = sorted(str(i) for i, c in Counter(agent_ids).items() if c > 1)
msg = f"Duplicate agent IDs in available_agents: {dupes}"
raise ValueError(msg)
if self.workloads:
workload_agent_ids = [w.agent_id for w in self.workloads]
if len(workload_agent_ids) != len(set(workload_agent_ids)):
from collections import Counter
dupes = sorted(i for i, c in Counter(workload_agent_ids).items() if c > 1)
msg = f"Duplicate agent_id in workloads: {dupes}"
raise ValueError(msg)
return self |
||
|
|
||
|
|
||
| class AssignmentResult(BaseModel): | ||
| """Result of a task assignment operation. | ||
|
|
||
| Attributes: | ||
| task_id: ID of the task that was assigned. | ||
| strategy_used: Name of the strategy that produced this result. | ||
| selected: The selected candidate (None if no viable agent). | ||
| alternatives: Other candidates considered, ranked by score. | ||
| reason: Human-readable explanation of the assignment decision. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(frozen=True) | ||
|
|
||
| task_id: NotBlankStr = Field(description="Task identifier") | ||
| strategy_used: NotBlankStr = Field( | ||
| description="Name of the strategy used", | ||
| ) | ||
| selected: AssignmentCandidate | None = Field( | ||
| default=None, | ||
| description="Selected candidate (None if no viable agent)", | ||
| ) | ||
| alternatives: tuple[AssignmentCandidate, ...] = Field( | ||
| default=(), | ||
| description="Other candidates considered, ranked by score", | ||
| ) | ||
| reason: NotBlankStr = Field(description="Explanation of decision") | ||
|
||
|
|
||
| @model_validator(mode="after") | ||
| def _validate_selected_not_in_alternatives(self) -> Self: | ||
| """Ensure selected candidate is not duplicated in alternatives.""" | ||
| if self.selected is None: | ||
| return self | ||
| selected_id = self.selected.agent_identity.id | ||
| for alt in self.alternatives: | ||
| if alt.agent_identity.id == selected_id: | ||
| selected_name = self.selected.agent_identity.name | ||
| msg = ( | ||
| f"Selected candidate {selected_name!r} also appears in alternatives" | ||
| ) | ||
| raise ValueError(msg) | ||
| return self | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
strategyfield is not validated against known strategy namesTaskAssignmentConfig.strategyaccepts anyNotBlankStrwith no validator checking it against the actual entries inSTRATEGY_MAP("manual","role_based","load_balanced"). A misconfigured value like"auction"or a typo like"role_base"passes config validation cleanly and will only fail (or be silently ignored) when the caller tries to resolve the strategy at runtime.Add a
@model_validatormethod to assert the value is a known key. The allowed set can be defined as a constant inengine/assignment/strategies.pyto avoid circular imports betweenconfigandenginemodules.Prompt To Fix With AI