Coverage for src / harnessutils / models / parts.py: 90%
156 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-04-07 09:04 -0600
« prev ^ index » next coverage.py v7.13.2, created at 2026-04-07 09:04 -0600
1"""Part types for message decomposition.
3Parts are the granular units that make up messages. This enables
4selective compaction where tool outputs can be cleared while
5preserving text and metadata.
6"""
8from dataclasses import dataclass, field
9from typing import Any, Literal
12@dataclass
13class TimeInfo:
14 """Timing information for parts."""
16 start: int # Unix timestamp in milliseconds
17 end: int | None = None
18 compacted: int | None = None # When output was compacted
21@dataclass
22class ToolState:
23 """State information for tool execution."""
25 status: Literal["pending", "running", "completed", "error"]
26 input: dict[str, Any] = field(default_factory=dict)
27 output: str = ""
28 summary_text: str | None = None # One-sentence summary generated before output was cleared
29 title: str = ""
30 metadata: dict[str, Any] = field(default_factory=dict)
31 error: str | None = None
32 time: TimeInfo | None = None
33 attachments: list[dict[str, Any]] = field(default_factory=list)
36@dataclass
37class Part:
38 """Base class for message parts."""
40 type: str = field(init=False)
41 time: TimeInfo | None = None
42 metadata: dict[str, Any] = field(default_factory=dict)
44 def to_dict(self) -> dict[str, Any]:
45 """Serialize part to dict for storage."""
46 d: dict[str, Any] = {"type": self.type, "metadata": self.metadata}
47 if self.time:
48 d["time"] = {
49 "start": self.time.start,
50 "end": self.time.end,
51 "compacted": self.time.compacted,
52 }
53 return d
56@dataclass
57class TextPart(Part):
58 """Text content part."""
60 text: str = ""
61 ignored: bool = False # If true, skip when converting to model format
63 def __post_init__(self) -> None:
64 self.type = "text"
66 def to_dict(self) -> dict[str, Any]:
67 d = super().to_dict()
68 d["text"] = self.text
69 d["ignored"] = self.ignored
70 return d
73@dataclass
74class ReasoningPart(Part):
75 """Extended thinking/reasoning content part."""
77 text: str = ""
79 def __post_init__(self) -> None:
80 self.type = "reasoning"
82 def to_dict(self) -> dict[str, Any]:
83 d = super().to_dict()
84 d["text"] = self.text
85 return d
88@dataclass
89class ToolPart(Part):
90 """Tool execution part."""
92 tool: str = ""
93 call_id: str = ""
94 state: ToolState = field(default_factory=lambda: ToolState(status="pending"))
96 def __post_init__(self) -> None:
97 self.type = "tool"
99 def to_dict(self) -> dict[str, Any]:
100 d = super().to_dict()
101 d["tool"] = self.tool
102 d["call_id"] = self.call_id
103 state = self.state
104 state_dict: dict[str, Any] = {
105 "status": state.status,
106 "input": state.input,
107 "output": state.output,
108 "summary_text": state.summary_text,
109 "title": state.title,
110 "metadata": state.metadata,
111 "attachments": state.attachments,
112 }
113 if state.error is not None:
114 state_dict["error"] = state.error
115 if state.time:
116 state_dict["time"] = {
117 "start": state.time.start,
118 "end": state.time.end,
119 "compacted": state.time.compacted,
120 }
121 d["state"] = state_dict
122 return d
125@dataclass
126class StepStartPart(Part):
127 """Step boundary marker - start of LLM turn."""
129 snapshot: str = "" # State snapshot ID
131 def __post_init__(self) -> None:
132 self.type = "step-start"
134 def to_dict(self) -> dict[str, Any]:
135 d = super().to_dict()
136 d["snapshot"] = self.snapshot
137 return d
140@dataclass
141class StepFinishPart(Part):
142 """Step boundary marker - end of LLM turn."""
144 reason: Literal["stop", "tool-calls", "length"] = "stop"
145 snapshot: str = "" # State snapshot ID
146 tokens: dict[str, Any] = field(default_factory=dict)
147 cost: float = 0.0
149 def __post_init__(self) -> None:
150 self.type = "step-finish"
152 def to_dict(self) -> dict[str, Any]:
153 d = super().to_dict()
154 d["reason"] = self.reason
155 d["snapshot"] = self.snapshot
156 d["tokens"] = self.tokens
157 d["cost"] = self.cost
158 return d
161@dataclass
162class CompactionPart(Part):
163 """Marker for compaction request."""
165 auto: bool = False # Was this auto-triggered?
167 def __post_init__(self) -> None:
168 self.type = "compaction"
170 def to_dict(self) -> dict[str, Any]:
171 d = super().to_dict()
172 d["auto"] = self.auto
173 return d
176@dataclass
177class PatchPart(Part):
178 """Code change marker."""
180 hash: str = "" # Diff hash
181 files: list[str] = field(default_factory=list)
183 def __post_init__(self) -> None:
184 self.type = "patch"
186 def to_dict(self) -> dict[str, Any]:
187 d = super().to_dict()
188 d["hash"] = self.hash
189 d["files"] = self.files
190 return d
193@dataclass
194class SubtaskPart(Part):
195 """Subtask invocation marker."""
197 prompt: str = ""
198 description: str = ""
199 agent: str = ""
200 model: dict[str, str] = field(default_factory=dict)
202 def __post_init__(self) -> None:
203 self.type = "subtask"
205 def to_dict(self) -> dict[str, Any]:
206 d = super().to_dict()
207 d["prompt"] = self.prompt
208 d["description"] = self.description
209 d["agent"] = self.agent
210 d["model"] = self.model
211 return d
214def part_from_dict(data: dict[str, Any]) -> Part:
215 """Deserialize a part from its stored dict representation."""
216 part_type = data.get("type", "")
218 def _time(d: dict[str, Any] | None) -> TimeInfo | None:
219 if not d:
220 return None
221 return TimeInfo(start=d["start"], end=d.get("end"), compacted=d.get("compacted"))
223 time = _time(data.get("time"))
224 metadata = data.get("metadata", {})
226 if part_type == "text":
227 p = TextPart(text=data.get("text", ""), ignored=data.get("ignored", False))
228 elif part_type == "reasoning":
229 p = ReasoningPart(text=data.get("text", ""))
230 elif part_type == "tool":
231 sd = data.get("state", {})
232 state = ToolState(
233 status=sd.get("status", "pending"),
234 input=sd.get("input", {}),
235 output=sd.get("output", ""),
236 summary_text=sd.get("summary_text"),
237 title=sd.get("title", ""),
238 metadata=sd.get("metadata", {}),
239 error=sd.get("error"),
240 time=_time(sd.get("time")),
241 attachments=sd.get("attachments", []),
242 )
243 p = ToolPart(tool=data.get("tool", ""), call_id=data.get("call_id", ""), state=state)
244 elif part_type == "step-start":
245 p = StepStartPart(snapshot=data.get("snapshot", ""))
246 elif part_type == "step-finish":
247 p = StepFinishPart(
248 reason=data.get("reason", "stop"),
249 snapshot=data.get("snapshot", ""),
250 tokens=data.get("tokens", {}),
251 cost=data.get("cost", 0.0),
252 )
253 elif part_type == "compaction":
254 p = CompactionPart(auto=data.get("auto", False))
255 elif part_type == "patch":
256 p = PatchPart(hash=data.get("hash", ""), files=data.get("files", []))
257 elif part_type == "subtask":
258 p = SubtaskPart(
259 prompt=data.get("prompt", ""),
260 description=data.get("description", ""),
261 agent=data.get("agent", ""),
262 model=data.get("model", {}),
263 )
264 else:
265 # Unknown type — create a generic part so we don't crash
266 p = TextPart(text="")
267 p.time = time
268 p.metadata = metadata
269 return p