deepharness 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ from deepharness.agent import (
2
+ FINAL_TOOL,
3
+ Agent,
4
+ AgentState,
5
+ Budget,
6
+ Ctx,
7
+ Finished,
8
+ Message,
9
+ PendingHumanInput,
10
+ StopReason,
11
+ TokenBudgetExceeded,
12
+ Toolbox,
13
+ ToolSpec,
14
+ load_session,
15
+ save_session,
16
+ tool,
17
+ )
18
+ from deepharness.errors import (
19
+ ConfigurationError,
20
+ DeepHarnessError,
21
+ HumanInputRequired,
22
+ ProviderError,
23
+ ToolNotFoundError,
24
+ )
25
+ from deepharness.graph import (
26
+ ConcurrentUpdateError,
27
+ ExecutionError,
28
+ Executor,
29
+ Graph,
30
+ NodeSpec,
31
+ StepLimitExceeded,
32
+ concat,
33
+ merge_dicts,
34
+ )
35
+ from deepharness.providers import (
36
+ VLLM,
37
+ XAI,
38
+ Anthropic,
39
+ Cerebras,
40
+ Completed,
41
+ DeepSeek,
42
+ Fireworks,
43
+ Gemini,
44
+ Groq,
45
+ LMStudio,
46
+ Mistral,
47
+ Ollama,
48
+ OpenAI,
49
+ OpenRouter,
50
+ TextDelta,
51
+ Together,
52
+ TokenUsage,
53
+ )
54
+
55
+ __all__ = [
56
+ "FINAL_TOOL",
57
+ "VLLM",
58
+ "XAI",
59
+ "Agent",
60
+ "AgentState",
61
+ "Anthropic",
62
+ "Budget",
63
+ "Cerebras",
64
+ "Completed",
65
+ "ConcurrentUpdateError",
66
+ "ConfigurationError",
67
+ "Ctx",
68
+ "DeepHarnessError",
69
+ "DeepSeek",
70
+ "ExecutionError",
71
+ "Executor",
72
+ "Finished",
73
+ "Fireworks",
74
+ "Gemini",
75
+ "Graph",
76
+ "Groq",
77
+ "HumanInputRequired",
78
+ "LMStudio",
79
+ "Message",
80
+ "Mistral",
81
+ "NodeSpec",
82
+ "Ollama",
83
+ "OpenAI",
84
+ "OpenRouter",
85
+ "PendingHumanInput",
86
+ "ProviderError",
87
+ "StepLimitExceeded",
88
+ "StopReason",
89
+ "TextDelta",
90
+ "Together",
91
+ "TokenBudgetExceeded",
92
+ "TokenUsage",
93
+ "ToolNotFoundError",
94
+ "ToolSpec",
95
+ "Toolbox",
96
+ "concat",
97
+ "load_session",
98
+ "merge_dicts",
99
+ "save_session",
100
+ "tool",
101
+ ]
@@ -0,0 +1,33 @@
1
+ from ..tools.toolbox import Ctx, Toolbox, ToolSpec, tool
2
+ from .loop import Agent, TokenBudgetExceeded
3
+ from .output import FINAL_TOOL
4
+ from .state import (
5
+ AgentState,
6
+ Budget,
7
+ Finished,
8
+ Message,
9
+ PendingHumanInput,
10
+ StopReason,
11
+ as_dict,
12
+ load_session,
13
+ save_session,
14
+ )
15
+
16
+ __all__ = [
17
+ "FINAL_TOOL",
18
+ "Agent",
19
+ "AgentState",
20
+ "Budget",
21
+ "Ctx",
22
+ "Finished",
23
+ "Message",
24
+ "PendingHumanInput",
25
+ "StopReason",
26
+ "TokenBudgetExceeded",
27
+ "ToolSpec",
28
+ "Toolbox",
29
+ "as_dict",
30
+ "load_session",
31
+ "save_session",
32
+ "tool",
33
+ ]
@@ -0,0 +1,414 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator, Callable, Generator, Iterable, Iterator
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from deepharness.providers.base import (
9
+ LLM,
10
+ TextDelta,
11
+ TokenUsage,
12
+ )
13
+
14
+ from ..errors import (
15
+ ConfigurationError,
16
+ OutputValidationError,
17
+ TokenBudgetExceeded,
18
+ )
19
+ from ..tools.toolbox import Ctx, Toolbox, ToolSpec
20
+ from . import turn
21
+ from .output import FINAL_TOOL, coerce, final_tool_schema, find_final
22
+ from .state import (
23
+ AgentState,
24
+ Budget,
25
+ Finished,
26
+ Message,
27
+ PendingHumanInput,
28
+ StopReason,
29
+ )
30
+
31
+ AgentEvent = TextDelta | Finished
32
+ """What streaming a run emits: prose as it arrives, then the final state."""
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class _Ask:
37
+ """A model call the loop needs its driver to perform."""
38
+
39
+ messages: list[dict[str, Any]]
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class _Dispatch:
44
+ """One turn's tool calls, for the driver to run however it runs them."""
45
+
46
+ calls: list[Any]
47
+
48
+
49
+ class Agent:
50
+ """A model plus tools, run as a think/act loop over an AgentState.
51
+
52
+ Ask the model, dispatch whatever tools it requests, repeat until it answers
53
+ or the budget runs out. Without a model the run is a passthrough, which is
54
+ what makes an Agent usable as a placeholder node in a Graph.
55
+
56
+ The parts worth knowing before reading the loop:
57
+
58
+ * Only stop_reason == "answer" means the model actually replied. Every other
59
+ reason leaves output empty or partial, so state.answered is the check to
60
+ make before trusting it.
61
+ * run() is a real synchronous path, not arun() wrapped in an event loop, so
62
+ it raises for a tool that turns out to be `async def`. arun() dispatches a
63
+ turn's tools concurrently, sync ones included - those go to threads.
64
+ * A failing tool does not end the run: the error becomes that call's result
65
+ so the model gets a turn to correct itself.
66
+ * total_usage accumulates across every model call this instance makes, not
67
+ per run, and Budget(tokens=...) turns crossing it into TokenBudgetExceeded
68
+ with the partial state attached.
69
+ * Every public entry point - arun, run, astream, stream - is the same loop;
70
+ only the I/O differs. See astream_events for the one async driver.
71
+
72
+ Pausing has two flavours, and they resolve differently: a tool marked
73
+ requires_approval has not run yet (approve it and it runs), while a tool
74
+ raising HumanInputRequired is asking a question (your answer becomes its
75
+ result). See deepharness/agent/turn.py.
76
+ """
77
+
78
+ __slots__ = (
79
+ "_budget",
80
+ "_final_schema",
81
+ "_model",
82
+ "_name",
83
+ "_output",
84
+ "_system",
85
+ "_tools",
86
+ "_total_usage",
87
+ )
88
+
89
+ def __init__(
90
+ self,
91
+ model: LLM | None = None,
92
+ *,
93
+ tools: Iterable[Callable[..., Any]] | Toolbox = (),
94
+ system: str | None = None,
95
+ name: str = "agent",
96
+ budget: Budget | None = None,
97
+ output: type | None = None,
98
+ ):
99
+ self._model = model
100
+ self._tools = tools if isinstance(tools, Toolbox) else Toolbox(tools)
101
+ self._system = system
102
+ self._name = name
103
+ self._budget = budget or Budget()
104
+ self._output = output
105
+ self._final_schema = final_tool_schema(output) if output is not None else None
106
+ self._total_usage = TokenUsage(0, 0, 0)
107
+
108
+ # Read-only views: an agent's configuration is settled at construction, and
109
+ # total_usage is live state no caller should be able to reset or inflate.
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return self._name
114
+
115
+ @property
116
+ def model(self) -> LLM | None:
117
+ return self._model
118
+
119
+ @property
120
+ def tools(self) -> Toolbox:
121
+ return self._tools
122
+
123
+ @property
124
+ def system(self) -> str | None:
125
+ return self._system
126
+
127
+ @property
128
+ def budget(self) -> Budget:
129
+ return self._budget
130
+
131
+ @property
132
+ def output(self) -> type | None:
133
+ return self._output
134
+
135
+ @property
136
+ def total_usage(self) -> TokenUsage:
137
+ """Cumulative usage across every model call this agent has made."""
138
+ return self._total_usage
139
+
140
+ def as_tool(
141
+ self, *, name: str | None = None, description: str | None = None
142
+ ) -> Callable[..., Any]:
143
+ """Wrap this Agent as a tool callable from another Agent's toolbox.
144
+
145
+ The wrapped tool takes a single `input` string, runs it through
146
+ arun() as a user message, and returns the resulting output text.
147
+ It's async, so register it with an Agent that calls arun() -
148
+ call_sync() raises ConfigurationError for async tools, same as any
149
+ other async tool.
150
+
151
+ The sub-agent inherits the caller's deps, so a delegated run keeps the
152
+ database handle or tenant the parent was given.
153
+ """
154
+
155
+ async def call(input: str, ctx: Ctx) -> str:
156
+ result = await self.arun(input, deps=ctx.deps)
157
+ return result.output
158
+
159
+ call.__name__ = name or self._name
160
+ call._tool_spec = ToolSpec( # type: ignore[attr-defined]
161
+ name=name or self._name,
162
+ description=description
163
+ or self._system
164
+ or f"Delegate a task to the '{self._name}' agent.",
165
+ parameters={
166
+ "type": "object",
167
+ "properties": {"input": {"type": "string"}},
168
+ "required": ["input"],
169
+ },
170
+ func=call,
171
+ ctx_params=("ctx",),
172
+ )
173
+ return call
174
+
175
+ def _account_for_usage(
176
+ self,
177
+ response: Any,
178
+ state: AgentState,
179
+ messages: list[dict[str, Any]],
180
+ ) -> None:
181
+ if response.usage is None:
182
+ return
183
+
184
+ self._total_usage = self._total_usage + response.usage
185
+ if (
186
+ self._budget.tokens is not None
187
+ and self._total_usage.total_tokens > self._budget.tokens
188
+ ):
189
+ raise TokenBudgetExceeded(
190
+ self._name,
191
+ self._total_usage,
192
+ self._budget.tokens,
193
+ state=self._result(state, messages, response.content, "token_budget"),
194
+ )
195
+
196
+ def _result(
197
+ self,
198
+ state: AgentState,
199
+ messages: list[dict[str, Any]],
200
+ output: Any,
201
+ stop_reason: StopReason,
202
+ paused: list[PendingHumanInput] | None = None,
203
+ ) -> AgentState:
204
+ return AgentState(
205
+ messages=messages,
206
+ output=output,
207
+ usage=self._total_usage,
208
+ stop_reason=stop_reason,
209
+ paused=paused or [],
210
+ )
211
+
212
+ def _turns(
213
+ self, state: AgentState, messages: list[dict[str, Any]]
214
+ ) -> Generator[_Ask | _Dispatch, Any, AgentState]:
215
+ """The think/act loop itself, with the I/O lifted out of it.
216
+
217
+ The loop yields the work it needs done - a model call, or a round of
218
+ tool calls - and its driver performs that work and sends the result
219
+ back. run() can then stay a genuinely synchronous path and arun() a
220
+ concurrent one, while everything they agree on (the step budget, stop
221
+ reasons, pausing for a human, feeding a failed tool back to the model)
222
+ lives here once instead of being maintained in two copies.
223
+
224
+ A run that starts from a paused state settles the approvals first: the
225
+ allowed calls run now, with the arguments the model originally sent, and
226
+ only then does the loop go back to the model with their results.
227
+ """
228
+ approved = turn.settle(state, messages, self._name)
229
+ if approved:
230
+ results = yield _Dispatch(approved)
231
+ turn.record_results(messages, approved, results)
232
+
233
+ for _ in range(self._budget.steps):
234
+ response = yield _Ask(messages)
235
+ self._account_for_usage(response, state, messages)
236
+
237
+ final = find_final(response) if self._final_schema else None
238
+ if final is not None:
239
+ turn.record_request(messages, response)
240
+ try:
241
+ answer = coerce(self._output, final.arguments)
242
+ except OutputValidationError as exc:
243
+ # Same courtesy a failing tool gets: hand the model the
244
+ # error so it can call FINAL_TOOL again with valid fields.
245
+ messages.append(
246
+ Message.tool(
247
+ f"Error: {exc}", name=FINAL_TOOL, call_id=final.id
248
+ ).to_dict()
249
+ )
250
+ continue
251
+ return self._result(state, messages, answer, "answer")
252
+
253
+ if not response.tool_calls:
254
+ messages.append(Message.ai(response.content).to_dict())
255
+ if self._final_schema is not None:
256
+ # output= was asked for, so plain prose is not an answer yet.
257
+ messages.append(
258
+ Message.human(f"Answer by calling {FINAL_TOOL}.").to_dict()
259
+ )
260
+ continue
261
+ return self._result(state, messages, response.content, "answer")
262
+
263
+ if not self._tools:
264
+ raise ConfigurationError(
265
+ f"{self._name} received tool calls but has no tools registered"
266
+ )
267
+
268
+ turn.record_request(messages, response)
269
+ wanted = [call for call in response.tool_calls if call.name != FINAL_TOOL]
270
+ gated = turn.gated(self._tools, wanted)
271
+ if gated:
272
+ # Nothing in this turn runs until the human rules on the gated
273
+ # call: letting the rest run first would half-apply a turn the
274
+ # human may be about to refuse.
275
+ return self._result(state, messages, "", "paused", paused=gated)
276
+
277
+ results = yield _Dispatch(wanted)
278
+ dispatched = wanted
279
+ pending = turn.record_results(messages, dispatched, results)
280
+ if pending:
281
+ return self._result(state, messages, "", "paused", paused=pending)
282
+
283
+ return self._result(state, messages, "", "step_budget")
284
+
285
+ def _passthrough(self, state: AgentState) -> AgentState:
286
+ """Without a model an Agent is inert - a placeholder node in a Graph.
287
+
288
+ Returns the state untouched rather than announcing itself: printing from
289
+ inside the agent would make this path untestable without capturing
290
+ stdout, and a library has no business writing to a caller's console.
291
+ """
292
+ return state
293
+
294
+ def _schemas(self) -> list[dict[str, Any]] | None:
295
+ schemas = self._tools.schemas()
296
+ if self._final_schema is not None:
297
+ schemas.append(self._final_schema)
298
+ return schemas or None
299
+
300
+ async def arun(self, state: Any = None, *, deps: Any = None) -> AgentState:
301
+ """Run to completion. Tool calls in the same turn dispatch concurrently."""
302
+ async for event in self.astream_events(state, deps=deps):
303
+ if isinstance(event, Finished):
304
+ return event.state
305
+ raise AssertionError("a run always ends with Finished") # pragma: no cover
306
+
307
+ def run(self, state: Any = None, *, deps: Any = None) -> AgentState:
308
+ """Synchronous counterpart to arun(). Raises if a tool is `async def`."""
309
+ for event in self.stream_events(state, deps=deps):
310
+ if isinstance(event, Finished):
311
+ return event.state
312
+ raise AssertionError("a run always ends with Finished") # pragma: no cover
313
+
314
+ async def astream_events(
315
+ self, state: Any = None, *, deps: Any = None
316
+ ) -> AsyncIterator[AgentEvent]:
317
+ """Drive one run, emitting prose as it arrives then the final state.
318
+
319
+ The only async driver: arun() consumes this and keeps the last event, so
320
+ the loop's mechanics - budget, approvals, tool dispatch - exist once
321
+ rather than once per public method. Providers that cannot really stream
322
+ still work here; their turn simply arrives as a single delta.
323
+ """
324
+ state = AgentState.of(state)
325
+ if self._model is None:
326
+ yield Finished(self._passthrough(state))
327
+ return
328
+
329
+ ctx = Ctx(state=state, deps=deps)
330
+ turns = self._turns(state, turn.prepare(state, self._system))
331
+ schemas = self._schemas()
332
+ outcome: Any = None
333
+ try:
334
+ while True:
335
+ request = turns.send(outcome)
336
+ if isinstance(request, _Ask):
337
+ async for event in self._model.astream_events(
338
+ request.messages, tools=schemas
339
+ ):
340
+ if isinstance(event, TextDelta):
341
+ yield event
342
+ else:
343
+ outcome = event.response
344
+ else:
345
+ outcome = await asyncio.gather(
346
+ *(
347
+ self._call_tool(call.name, call.arguments, ctx)
348
+ for call in request.calls
349
+ )
350
+ )
351
+ except StopIteration as done:
352
+ yield Finished(done.value)
353
+
354
+ def stream_events(
355
+ self, state: Any = None, *, deps: Any = None
356
+ ) -> Iterator[AgentEvent]:
357
+ """Synchronous counterpart to astream_events()."""
358
+ state = AgentState.of(state)
359
+ if self._model is None:
360
+ yield Finished(self._passthrough(state))
361
+ return
362
+
363
+ ctx = Ctx(state=state, deps=deps)
364
+ turns = self._turns(state, turn.prepare(state, self._system))
365
+ schemas = self._schemas()
366
+ outcome: Any = None
367
+ try:
368
+ while True:
369
+ request = turns.send(outcome)
370
+ if isinstance(request, _Ask):
371
+ for event in self._model.stream_events(
372
+ request.messages, tools=schemas
373
+ ):
374
+ if isinstance(event, TextDelta):
375
+ yield event
376
+ else:
377
+ outcome = event.response
378
+ else:
379
+ outcome = [
380
+ self._call_tool_sync(call.name, call.arguments, ctx)
381
+ for call in request.calls
382
+ ]
383
+ except StopIteration as done:
384
+ yield Finished(done.value)
385
+
386
+ async def astream(
387
+ self, state: Any = None, *, deps: Any = None
388
+ ) -> AsyncIterator[str]:
389
+ """Just the text, for the common "print as it types" case."""
390
+ async for event in self.astream_events(state, deps=deps):
391
+ if isinstance(event, TextDelta):
392
+ yield event.text
393
+
394
+ def stream(self, state: Any = None, *, deps: Any = None) -> Iterator[str]:
395
+ """Synchronous counterpart to astream()."""
396
+ for event in self.stream_events(state, deps=deps):
397
+ if isinstance(event, TextDelta):
398
+ yield event.text
399
+
400
+ async def _call_tool(self, name: str, arguments: dict[str, Any], ctx: Ctx) -> Any:
401
+ try:
402
+ return await self._tools.call(name, ctx=ctx, **arguments)
403
+ except ConfigurationError:
404
+ raise
405
+ except Exception as exc: # noqa: BLE001 - see transcript.record_results
406
+ return exc
407
+
408
+ def _call_tool_sync(self, name: str, arguments: dict[str, Any], ctx: Ctx) -> Any:
409
+ try:
410
+ return self._tools.call_sync(name, ctx=ctx, **arguments)
411
+ except ConfigurationError:
412
+ raise
413
+ except Exception as exc: # noqa: BLE001 - see transcript.record_results
414
+ return exc