Skip to content

Commit 829f47f

Browse files
coordtclaude
andcommitted
Phase 2 Task 4: implement agent protocol Pydantic models
Add TaskMessage, DecisionMessage, ActionItem, LLMBackendRef, TaskContext, and DecisionType to foreman/protocol.py with 22 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9f21485 commit 829f47f

2 files changed

Lines changed: 275 additions & 1 deletion

File tree

foreman/protocol.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,92 @@
1-
"""Pydantic models for the harness↔agent message protocol."""
1+
"""Pydantic models for the harness↔agent message protocol.
2+
3+
Task (harness → agent) and Decision (agent → harness) message contracts,
4+
plus supporting types.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import uuid
10+
from enum import Enum
11+
from typing import Any, Optional
12+
13+
from pydantic import BaseModel, Field
14+
15+
16+
class ActionItem(BaseModel):
17+
"""A single action to be executed by the harness after a decision.
18+
19+
Extra fields are allowed to support future action types without schema changes.
20+
"""
21+
22+
model_config = {"extra": "allow"}
23+
24+
type: str
25+
"""Action type identifier (e.g. ``add_label``, ``comment``)."""
26+
27+
28+
class LLMBackendRef(BaseModel):
29+
"""Reference to the LLM backend the agent should use."""
30+
31+
provider: str
32+
"""LLM provider identifier (e.g. ``anthropic``, ``ollama``)."""
33+
34+
model: str
35+
"""Model name / identifier (e.g. ``claude-sonnet-4-6``)."""
36+
37+
38+
class TaskContext(BaseModel):
39+
"""Context injected by the harness into each task."""
40+
41+
llm_backend: LLMBackendRef
42+
"""LLM backend the agent should use for this task."""
43+
44+
memory_summary: Optional[str] = None
45+
"""LLM-generated summary of prior actions on this issue/repo, if any."""
46+
47+
48+
class TaskMessage(BaseModel):
49+
"""Task sent from the harness to an agent container.
50+
51+
The harness generates a ``task_id`` automatically if not supplied.
52+
"""
53+
54+
task_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
55+
"""Unique identifier for this task (UUID4)."""
56+
57+
type: str
58+
"""Task type (e.g. ``issue.triage``)."""
59+
60+
repo: str
61+
"""Repository in ``owner/repo`` format."""
62+
63+
payload: dict[str, Any]
64+
"""Raw GitHub event payload."""
65+
66+
context: TaskContext
67+
"""Harness-injected context (memory summary, LLM backend)."""
68+
69+
70+
class DecisionType(str, Enum):
71+
"""Valid agent decision values."""
72+
73+
label_and_respond = "label_and_respond"
74+
close = "close"
75+
escalate = "escalate"
76+
skip = "skip"
77+
78+
79+
class DecisionMessage(BaseModel):
80+
"""Decision returned from an agent to the harness."""
81+
82+
task_id: str
83+
"""Must match the ``task_id`` from the corresponding :class:`TaskMessage`."""
84+
85+
decision: DecisionType
86+
"""The agent's decision on how to handle the task."""
87+
88+
rationale: str
89+
"""Human-readable explanation of the decision."""
90+
91+
actions: list[ActionItem] = []
92+
"""Ordered list of actions for the harness to execute."""

tests/test_protocol.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Tests for foreman/protocol.py — TaskMessage, DecisionMessage, ActionItem."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
7+
import pytest
8+
from pydantic import ValidationError
9+
10+
from foreman.protocol import ActionItem, DecisionMessage, DecisionType, LLMBackendRef, TaskContext, TaskMessage
11+
12+
13+
class TestActionItem:
14+
"""Tests for ActionItem model."""
15+
16+
def test_add_label_action(self) -> None:
17+
"""ActionItem with add_label type and label field."""
18+
action = ActionItem(type="add_label", label="bug")
19+
assert action.type == "add_label"
20+
assert action.label == "bug"
21+
22+
def test_comment_action(self) -> None:
23+
"""ActionItem with comment type and body field."""
24+
action = ActionItem(type="comment", body="Thanks for the report.")
25+
assert action.type == "comment"
26+
assert action.body == "Thanks for the report."
27+
28+
def test_extra_fields_allowed(self) -> None:
29+
"""ActionItem accepts arbitrary extra fields for extensibility."""
30+
action = ActionItem(type="custom_action", custom_field="value")
31+
assert action.type == "custom_action"
32+
assert action.custom_field == "value"
33+
34+
def test_type_required(self) -> None:
35+
"""ActionItem requires the type field."""
36+
with pytest.raises(ValidationError):
37+
ActionItem() # type: ignore[call-arg]
38+
39+
40+
class TestLLMBackendRef:
41+
"""Tests for LLMBackendRef model."""
42+
43+
def test_valid_ref(self) -> None:
44+
"""LLMBackendRef stores provider and model."""
45+
ref = LLMBackendRef(provider="anthropic", model="claude-sonnet-4-6")
46+
assert ref.provider == "anthropic"
47+
assert ref.model == "claude-sonnet-4-6"
48+
49+
def test_provider_required(self) -> None:
50+
"""LLMBackendRef requires provider."""
51+
with pytest.raises(ValidationError):
52+
LLMBackendRef(model="claude-sonnet-4-6") # type: ignore[call-arg]
53+
54+
def test_model_required(self) -> None:
55+
"""LLMBackendRef requires model."""
56+
with pytest.raises(ValidationError):
57+
LLMBackendRef(provider="anthropic") # type: ignore[call-arg]
58+
59+
60+
class TestTaskContext:
61+
"""Tests for TaskContext model."""
62+
63+
def test_full_context(self) -> None:
64+
"""TaskContext stores memory_summary and llm_backend."""
65+
backend = LLMBackendRef(provider="anthropic", model="claude-sonnet-4-6")
66+
ctx = TaskContext(memory_summary="Prior actions: labeled as bug.", llm_backend=backend)
67+
assert ctx.memory_summary == "Prior actions: labeled as bug."
68+
assert ctx.llm_backend.provider == "anthropic"
69+
70+
def test_memory_summary_optional(self) -> None:
71+
"""memory_summary defaults to None."""
72+
backend = LLMBackendRef(provider="ollama", model="llama3")
73+
ctx = TaskContext(llm_backend=backend)
74+
assert ctx.memory_summary is None
75+
76+
def test_llm_backend_required(self) -> None:
77+
"""llm_backend is required."""
78+
with pytest.raises(ValidationError):
79+
TaskContext() # type: ignore[call-arg]
80+
81+
82+
class TestTaskMessage:
83+
"""Tests for TaskMessage model."""
84+
85+
def _make_task(self, **overrides: object) -> TaskMessage:
86+
defaults: dict[str, object] = {
87+
"type": "issue.triage",
88+
"repo": "owner/repo",
89+
"payload": {"issue": {"number": 42}},
90+
"context": TaskContext(llm_backend=LLMBackendRef(provider="anthropic", model="claude-sonnet-4-6")),
91+
}
92+
defaults.update(overrides)
93+
return TaskMessage(**defaults) # type: ignore[arg-type]
94+
95+
def test_task_id_auto_generated(self) -> None:
96+
"""task_id is auto-generated as a UUID4 string when not provided."""
97+
task = self._make_task()
98+
# Must be a valid UUID
99+
parsed = uuid.UUID(task.task_id)
100+
assert parsed.version == 4
101+
102+
def test_task_id_explicit(self) -> None:
103+
"""Explicit task_id is preserved."""
104+
tid = str(uuid.uuid4())
105+
task = self._make_task(task_id=tid)
106+
assert task.task_id == tid
107+
108+
def test_required_fields(self) -> None:
109+
"""type, repo, payload, and context are required."""
110+
with pytest.raises(ValidationError):
111+
TaskMessage() # type: ignore[call-arg]
112+
113+
def test_field_values(self) -> None:
114+
"""TaskMessage stores all fields correctly."""
115+
task = self._make_task()
116+
assert task.type == "issue.triage"
117+
assert task.repo == "owner/repo"
118+
assert task.payload == {"issue": {"number": 42}}
119+
assert task.context.llm_backend.provider == "anthropic"
120+
121+
def test_roundtrip_json(self) -> None:
122+
"""TaskMessage serialises to JSON and back without loss."""
123+
task = self._make_task()
124+
roundtripped = TaskMessage.model_validate_json(task.model_dump_json())
125+
assert roundtripped.task_id == task.task_id
126+
assert roundtripped.type == task.type
127+
128+
129+
class TestDecisionMessage:
130+
"""Tests for DecisionMessage model."""
131+
132+
def _make_decision(self, **overrides: object) -> DecisionMessage:
133+
defaults: dict = {
134+
"task_id": str(uuid.uuid4()),
135+
"decision": DecisionType.label_and_respond,
136+
"rationale": "Issue matches bug pattern.",
137+
"actions": [ActionItem(type="add_label", label="bug")],
138+
}
139+
defaults.update(overrides)
140+
return DecisionMessage(**defaults)
141+
142+
def test_valid_decision(self) -> None:
143+
"""DecisionMessage stores all fields correctly."""
144+
d = self._make_decision()
145+
assert d.decision == DecisionType.label_and_respond
146+
assert d.rationale == "Issue matches bug pattern."
147+
assert len(d.actions) == 1
148+
149+
def test_decision_type_close(self) -> None:
150+
"""DecisionType.close is a valid decision."""
151+
d = self._make_decision(decision=DecisionType.close, actions=[])
152+
assert d.decision == DecisionType.close
153+
154+
def test_decision_type_escalate(self) -> None:
155+
"""DecisionType.escalate is a valid decision."""
156+
d = self._make_decision(decision=DecisionType.escalate, actions=[])
157+
assert d.decision == DecisionType.escalate
158+
159+
def test_decision_type_skip(self) -> None:
160+
"""DecisionType.skip is a valid decision."""
161+
d = self._make_decision(decision=DecisionType.skip, actions=[])
162+
assert d.decision == DecisionType.skip
163+
164+
def test_invalid_decision_raises(self) -> None:
165+
"""Unknown decision value raises ValidationError."""
166+
with pytest.raises(ValidationError):
167+
self._make_decision(decision="do_something_weird")
168+
169+
def test_actions_default_empty(self) -> None:
170+
"""actions defaults to an empty list."""
171+
d = DecisionMessage(
172+
task_id=str(uuid.uuid4()),
173+
decision=DecisionType.skip,
174+
rationale="No action needed.",
175+
)
176+
assert d.actions == []
177+
178+
def test_roundtrip_json(self) -> None:
179+
"""DecisionMessage serialises to JSON and back without loss."""
180+
d = self._make_decision()
181+
roundtripped = DecisionMessage.model_validate_json(d.model_dump_json())
182+
assert roundtripped.task_id == d.task_id
183+
assert roundtripped.decision == d.decision

0 commit comments

Comments
 (0)