openrecruiter 0.1.0__tar.gz

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.
Files changed (34) hide show
  1. openrecruiter-0.1.0/.gitignore +52 -0
  2. openrecruiter-0.1.0/PKG-INFO +175 -0
  3. openrecruiter-0.1.0/README.md +162 -0
  4. openrecruiter-0.1.0/openrecruiter/__init__.py +72 -0
  5. openrecruiter-0.1.0/openrecruiter/agent.py +237 -0
  6. openrecruiter-0.1.0/openrecruiter/client.py +280 -0
  7. openrecruiter-0.1.0/openrecruiter/config.py +66 -0
  8. openrecruiter-0.1.0/openrecruiter/context.py +130 -0
  9. openrecruiter-0.1.0/openrecruiter/events.py +84 -0
  10. openrecruiter-0.1.0/openrecruiter/prompts.py +79 -0
  11. openrecruiter-0.1.0/openrecruiter/providers/__init__.py +5 -0
  12. openrecruiter-0.1.0/openrecruiter/providers/llm.py +209 -0
  13. openrecruiter-0.1.0/openrecruiter/ranking/__init__.py +8 -0
  14. openrecruiter-0.1.0/openrecruiter/ranking/api.py +84 -0
  15. openrecruiter-0.1.0/openrecruiter/ranking/base.py +44 -0
  16. openrecruiter-0.1.0/openrecruiter/ranking/embedding.py +39 -0
  17. openrecruiter-0.1.0/openrecruiter/ranking/two_stage.py +56 -0
  18. openrecruiter-0.1.0/openrecruiter/store/__init__.py +13 -0
  19. openrecruiter-0.1.0/openrecruiter/store/base.py +97 -0
  20. openrecruiter-0.1.0/openrecruiter/store/sqlite.py +244 -0
  21. openrecruiter-0.1.0/openrecruiter/store/vector.py +169 -0
  22. openrecruiter-0.1.0/openrecruiter/tools/__init__.py +6 -0
  23. openrecruiter-0.1.0/openrecruiter/tools/base.py +155 -0
  24. openrecruiter-0.1.0/openrecruiter/tools/recruiting.py +217 -0
  25. openrecruiter-0.1.0/openrecruiter/types.py +133 -0
  26. openrecruiter-0.1.0/pyproject.toml +29 -0
  27. openrecruiter-0.1.0/tests/conftest.py +112 -0
  28. openrecruiter-0.1.0/tests/test_agent.py +273 -0
  29. openrecruiter-0.1.0/tests/test_client.py +270 -0
  30. openrecruiter-0.1.0/tests/test_context.py +104 -0
  31. openrecruiter-0.1.0/tests/test_llm.py +224 -0
  32. openrecruiter-0.1.0/tests/test_ranking.py +170 -0
  33. openrecruiter-0.1.0/tests/test_store.py +105 -0
  34. openrecruiter-0.1.0/tests/test_tools.py +90 -0
@@ -0,0 +1,52 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ venv/
10
+
11
+ # Node
12
+ node_modules/
13
+ product/frontend/dist/
14
+ product/electron/dist/
15
+
16
+ # Database
17
+ *.db
18
+ *.sqlite
19
+ chroma_data/
20
+
21
+ # Environment
22
+ .env
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+
28
+ # Cache
29
+ .pytest_cache/
30
+ .ruff_cache/
31
+ .mypy_cache/
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
36
+
37
+ # Credentials
38
+ credentials.json
39
+ token.json
40
+
41
+ # Uploads (resumes)
42
+ product/backend/uploads/
43
+
44
+ # Build artifacts
45
+ *.tsbuildinfo
46
+
47
+ # Electron build
48
+ release/
49
+ product/release/
50
+
51
+ # Python package builds
52
+ dist/
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.5
2
+ Name: openrecruiter
3
+ Version: 0.1.0
4
+ Summary: The recruiting engine behind Open Recruiter — parse, retrieve, rank, and an agent over tools.
5
+ License: MIT
6
+ Keywords: agent,ats,candidate-search,llm,ranking,recruiting
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: chromadb>=0.5
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: litellm>=1.60
11
+ Requires-Dist: pydantic>=2.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # openrecruiter
15
+
16
+ The recruiting engine behind [Open Recruiter](https://github.com/miao4ai/open_recruiter):
17
+ parse resumes and job descriptions, retrieve and rank candidates, draft outreach, and run an
18
+ agent that does all of it through tools.
19
+
20
+ The desktop app is a consumer of this package, so everything shipped here is exercised by a
21
+ real application rather than only by its own tests.
22
+
23
+ ## Install
24
+
25
+ Straight from the repository:
26
+
27
+ ```bash
28
+ pip install "git+https://github.com/miao4ai/open_recruiter.git#subdirectory=sdk/core"
29
+ ```
30
+
31
+ Or a released wheel, from the [Releases](https://github.com/miao4ai/open_recruiter/releases)
32
+ page:
33
+
34
+ ```bash
35
+ pip install https://github.com/miao4ai/open_recruiter/releases/download/sdk-core-v0.1.0/openrecruiter-0.1.0-py3-none-any.whl
36
+ ```
37
+
38
+ > Not on PyPI yet, so plain `pip install openrecruiter` will not find it. GitHub Packages has
39
+ > no Python registry, which is why the wheel is attached to a Release rather than appearing in
40
+ > the repository's Packages panel.
41
+
42
+ No local model is downloaded, at import or at runtime. Embeddings are an API call and chat is
43
+ a hosted provider, so it runs on CPU, on macOS, and in a container with no GPU — 97 packages
44
+ installed, none of them a training stack.
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from openrecruiter import Recruiter
50
+
51
+ r = Recruiter(anthropic_api_key="sk-ant-...", voyage_api_key="pa-...")
52
+
53
+ job = r.add_job(open("jd.txt").read()) # parsed into title, skills, requirements
54
+ r.add_candidate(open("resume.txt").read()) # parsed into a structured profile
55
+
56
+ for match in r.rank(job.id, top_k=10):
57
+ print(f"{match.score:.2f} {match.candidate_id} {match.reasoning}")
58
+ ```
59
+
60
+ Without a Voyage key, retrieval is disabled and ranking falls back to the LLM — the package
61
+ still works, it just reads every candidate instead of shortlisting first.
62
+
63
+ ## The agent
64
+
65
+ The model is given real tools and decides what to call and when it is done, so one request
66
+ can span several steps: rank a job, read the result, then draft the emails.
67
+
68
+ ```python
69
+ for event in r.chat("who are the three strongest fits for the CUDA role, and draft an intro to each"):
70
+ match event:
71
+ case TextDelta(): print(event.text, end="", flush=True)
72
+ case ToolCall(): print(f"\n[{event.name}]")
73
+ case ApprovalRequired(): ... # a gated tool is waiting for a human
74
+ ```
75
+
76
+ Events are `TextDelta`, `ToolCall`, `ToolResult`, `ApprovalRequired`, and `Finished`. Text
77
+ streams as it is generated; tool calls are only emitted once their arguments are complete.
78
+
79
+ For a one-liner, `r.ask("...")` returns just the final text.
80
+
81
+ ### Approval gates
82
+
83
+ A tool marked `requires_approval` stops the run rather than acting. Anything the model
84
+ queued behind it is held too, so the gate cannot be stepped around:
85
+
86
+ ```python
87
+ events = list(r.chat("email the top candidate"))
88
+ if agent.pending: # serialisable — store it, decide later
89
+ list(agent.resume(agent.pending, approved=True))
90
+ ```
91
+
92
+ ### Your own tools
93
+
94
+ ```python
95
+ from openrecruiter import Tool
96
+
97
+ check_calendar = Tool(
98
+ name="check_calendar",
99
+ description="Look at the recruiter's availability this week",
100
+ parameters={"type": "object", "properties": {"days": {"type": "integer"}}},
101
+ fn=lambda days=7: my_calendar.free_slots(days),
102
+ )
103
+
104
+ r = Recruiter(anthropic_api_key="...", extra_tools=[check_calendar])
105
+ ```
106
+
107
+ ## Ranking
108
+
109
+ Ranking is the main extension point. One interface, several backends:
110
+
111
+ ```
112
+ Ranker
113
+ ├── EmbeddingRanker vector similarity — the default, cheap enough for the whole pool
114
+ ├── APIRanker an LLM scores each candidate and explains itself
115
+ ├── TwoStageRanker retrieve with one, rerank the shortlist with the other
116
+ └── your own implement rank(job, candidates, top_k) and pass it in
117
+ ```
118
+
119
+ `TwoStageRanker` is the shape the ranking research targets — the first stage optimises
120
+ recall, the second optimises relevance over a few hundred candidates:
121
+
122
+ ```python
123
+ from openrecruiter import APIRanker, EmbeddingRanker, TwoStageRanker
124
+
125
+ r.ranker = TwoStageRanker(
126
+ EmbeddingRanker(r.index),
127
+ APIRanker(r.llm),
128
+ shortlist=200,
129
+ )
130
+ ```
131
+
132
+ Backends that need a local model live in their own distributions — `recruitgpt` for the
133
+ distilled ranker, `openrecruiter-fairness` for bias-aware reranking — so nothing heavy
134
+ reaches this install. Both must lazy-load: no download until a user selects that backend.
135
+
136
+ ## Bringing your own storage
137
+
138
+ `Store` and `VectorIndex` are protocols. Implement them over a database you already have and
139
+ nothing above the storage layer changes — that is how the desktop app keeps its existing
140
+ schema while running on this package.
141
+
142
+ ```python
143
+ class MyStore:
144
+ def add_job(self, job): ...
145
+ def get_job(self, job_id): ...
146
+ def list_jobs(self, limit=100): ...
147
+ # ... six more, all in openrecruiter.store.base
148
+
149
+ r = Recruiter(config, store=MyStore())
150
+ ```
151
+
152
+ The default `SQLiteStore` is a working reference in four tables.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ cd sdk/core
158
+ uv sync
159
+ uv run pytest # no network calls: the LLM is faked end to end
160
+ uv build --out-dir dist
161
+ ```
162
+
163
+ Releasing: bump `version` in `pyproject.toml`, then push a matching tag.
164
+
165
+ ```bash
166
+ git tag sdk-core-v0.1.1 && git push origin sdk-core-v0.1.1
167
+ ```
168
+
169
+ CI checks the tag against the version, runs the tests, builds, installs the wheel in a clean
170
+ environment and imports it, verifies no training stack came along, and attaches the artifacts
171
+ to a Release.
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,162 @@
1
+ # openrecruiter
2
+
3
+ The recruiting engine behind [Open Recruiter](https://github.com/miao4ai/open_recruiter):
4
+ parse resumes and job descriptions, retrieve and rank candidates, draft outreach, and run an
5
+ agent that does all of it through tools.
6
+
7
+ The desktop app is a consumer of this package, so everything shipped here is exercised by a
8
+ real application rather than only by its own tests.
9
+
10
+ ## Install
11
+
12
+ Straight from the repository:
13
+
14
+ ```bash
15
+ pip install "git+https://github.com/miao4ai/open_recruiter.git#subdirectory=sdk/core"
16
+ ```
17
+
18
+ Or a released wheel, from the [Releases](https://github.com/miao4ai/open_recruiter/releases)
19
+ page:
20
+
21
+ ```bash
22
+ pip install https://github.com/miao4ai/open_recruiter/releases/download/sdk-core-v0.1.0/openrecruiter-0.1.0-py3-none-any.whl
23
+ ```
24
+
25
+ > Not on PyPI yet, so plain `pip install openrecruiter` will not find it. GitHub Packages has
26
+ > no Python registry, which is why the wheel is attached to a Release rather than appearing in
27
+ > the repository's Packages panel.
28
+
29
+ No local model is downloaded, at import or at runtime. Embeddings are an API call and chat is
30
+ a hosted provider, so it runs on CPU, on macOS, and in a container with no GPU — 97 packages
31
+ installed, none of them a training stack.
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ from openrecruiter import Recruiter
37
+
38
+ r = Recruiter(anthropic_api_key="sk-ant-...", voyage_api_key="pa-...")
39
+
40
+ job = r.add_job(open("jd.txt").read()) # parsed into title, skills, requirements
41
+ r.add_candidate(open("resume.txt").read()) # parsed into a structured profile
42
+
43
+ for match in r.rank(job.id, top_k=10):
44
+ print(f"{match.score:.2f} {match.candidate_id} {match.reasoning}")
45
+ ```
46
+
47
+ Without a Voyage key, retrieval is disabled and ranking falls back to the LLM — the package
48
+ still works, it just reads every candidate instead of shortlisting first.
49
+
50
+ ## The agent
51
+
52
+ The model is given real tools and decides what to call and when it is done, so one request
53
+ can span several steps: rank a job, read the result, then draft the emails.
54
+
55
+ ```python
56
+ for event in r.chat("who are the three strongest fits for the CUDA role, and draft an intro to each"):
57
+ match event:
58
+ case TextDelta(): print(event.text, end="", flush=True)
59
+ case ToolCall(): print(f"\n[{event.name}]")
60
+ case ApprovalRequired(): ... # a gated tool is waiting for a human
61
+ ```
62
+
63
+ Events are `TextDelta`, `ToolCall`, `ToolResult`, `ApprovalRequired`, and `Finished`. Text
64
+ streams as it is generated; tool calls are only emitted once their arguments are complete.
65
+
66
+ For a one-liner, `r.ask("...")` returns just the final text.
67
+
68
+ ### Approval gates
69
+
70
+ A tool marked `requires_approval` stops the run rather than acting. Anything the model
71
+ queued behind it is held too, so the gate cannot be stepped around:
72
+
73
+ ```python
74
+ events = list(r.chat("email the top candidate"))
75
+ if agent.pending: # serialisable — store it, decide later
76
+ list(agent.resume(agent.pending, approved=True))
77
+ ```
78
+
79
+ ### Your own tools
80
+
81
+ ```python
82
+ from openrecruiter import Tool
83
+
84
+ check_calendar = Tool(
85
+ name="check_calendar",
86
+ description="Look at the recruiter's availability this week",
87
+ parameters={"type": "object", "properties": {"days": {"type": "integer"}}},
88
+ fn=lambda days=7: my_calendar.free_slots(days),
89
+ )
90
+
91
+ r = Recruiter(anthropic_api_key="...", extra_tools=[check_calendar])
92
+ ```
93
+
94
+ ## Ranking
95
+
96
+ Ranking is the main extension point. One interface, several backends:
97
+
98
+ ```
99
+ Ranker
100
+ ├── EmbeddingRanker vector similarity — the default, cheap enough for the whole pool
101
+ ├── APIRanker an LLM scores each candidate and explains itself
102
+ ├── TwoStageRanker retrieve with one, rerank the shortlist with the other
103
+ └── your own implement rank(job, candidates, top_k) and pass it in
104
+ ```
105
+
106
+ `TwoStageRanker` is the shape the ranking research targets — the first stage optimises
107
+ recall, the second optimises relevance over a few hundred candidates:
108
+
109
+ ```python
110
+ from openrecruiter import APIRanker, EmbeddingRanker, TwoStageRanker
111
+
112
+ r.ranker = TwoStageRanker(
113
+ EmbeddingRanker(r.index),
114
+ APIRanker(r.llm),
115
+ shortlist=200,
116
+ )
117
+ ```
118
+
119
+ Backends that need a local model live in their own distributions — `recruitgpt` for the
120
+ distilled ranker, `openrecruiter-fairness` for bias-aware reranking — so nothing heavy
121
+ reaches this install. Both must lazy-load: no download until a user selects that backend.
122
+
123
+ ## Bringing your own storage
124
+
125
+ `Store` and `VectorIndex` are protocols. Implement them over a database you already have and
126
+ nothing above the storage layer changes — that is how the desktop app keeps its existing
127
+ schema while running on this package.
128
+
129
+ ```python
130
+ class MyStore:
131
+ def add_job(self, job): ...
132
+ def get_job(self, job_id): ...
133
+ def list_jobs(self, limit=100): ...
134
+ # ... six more, all in openrecruiter.store.base
135
+
136
+ r = Recruiter(config, store=MyStore())
137
+ ```
138
+
139
+ The default `SQLiteStore` is a working reference in four tables.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ cd sdk/core
145
+ uv sync
146
+ uv run pytest # no network calls: the LLM is faked end to end
147
+ uv build --out-dir dist
148
+ ```
149
+
150
+ Releasing: bump `version` in `pyproject.toml`, then push a matching tag.
151
+
152
+ ```bash
153
+ git tag sdk-core-v0.1.1 && git push origin sdk-core-v0.1.1
154
+ ```
155
+
156
+ CI checks the tag against the version, runs the tests, builds, installs the wheel in a clean
157
+ environment and imports it, verifies no training stack came along, and attaches the artifacts
158
+ to a Release.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,72 @@
1
+ """Open Recruiter — the recruiting engine behind the Open Recruiter app.
2
+
3
+ Parse resumes and job descriptions, retrieve and rank candidates, draft
4
+ outreach, and run an agent that does all of it through tools.
5
+
6
+ from openrecruiter import Recruiter
7
+
8
+ r = Recruiter(anthropic_api_key="sk-ant-...")
9
+ job = r.add_job(jd_text)
10
+ matches = r.rank(job.id, top_k=10)
11
+
12
+ No local model is downloaded, at import or at runtime. Embeddings are an API
13
+ call and chat is a hosted provider, so the package runs on CPU, on macOS, and in
14
+ a container without a GPU.
15
+ """
16
+
17
+ from openrecruiter.agent import Agent, PendingApproval
18
+ from openrecruiter.client import Recruiter
19
+ from openrecruiter.config import Config
20
+ from openrecruiter.context import build_pipeline_context
21
+ from openrecruiter.events import (
22
+ ApprovalRequired,
23
+ Event,
24
+ Finished,
25
+ TextDelta,
26
+ ToolCall,
27
+ ToolResult,
28
+ )
29
+ from openrecruiter.ranking import APIRanker, EmbeddingRanker, Ranker, TwoStageRanker
30
+ from openrecruiter.store import (
31
+ ChromaVectorIndex,
32
+ NullVectorIndex,
33
+ SQLiteStore,
34
+ Store,
35
+ VectorIndex,
36
+ )
37
+ from openrecruiter.tools import Tool, ToolRegistry, tool
38
+ from openrecruiter.types import Candidate, CandidateStatus, EmailDraft, Job, Match
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ "APIRanker",
44
+ "Agent",
45
+ "ApprovalRequired",
46
+ "build_pipeline_context",
47
+ "Candidate",
48
+ "CandidateStatus",
49
+ "ChromaVectorIndex",
50
+ "Config",
51
+ "EmailDraft",
52
+ "EmbeddingRanker",
53
+ "Event",
54
+ "Finished",
55
+ "Job",
56
+ "Match",
57
+ "NullVectorIndex",
58
+ "PendingApproval",
59
+ "Ranker",
60
+ "Recruiter",
61
+ "SQLiteStore",
62
+ "Store",
63
+ "TextDelta",
64
+ "Tool",
65
+ "ToolCall",
66
+ "ToolRegistry",
67
+ "ToolResult",
68
+ "TwoStageRanker",
69
+ "VectorIndex",
70
+ "__version__",
71
+ "tool",
72
+ ]
@@ -0,0 +1,237 @@
1
+ """The agent loop.
2
+
3
+ The model is given real tools and decides for itself what to call, in what
4
+ order, and when it is finished. That is the whole difference from asking a model
5
+ to emit an action name and dispatching it: a single request can retrieve a job,
6
+ rank the pool, read the top result, and draft three emails, because each tool
7
+ result feeds the next decision.
8
+
9
+ The loop is a generator of `Event`s so a caller can render text as it arrives
10
+ rather than after the whole turn completes, and so it can stop at an approval
11
+ gate without the agent needing to know what a user interface is.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from collections.abc import Iterator
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from openrecruiter.events import (
23
+ ApprovalRequired,
24
+ Event,
25
+ Finished,
26
+ TextDelta,
27
+ ToolCall,
28
+ ToolResult,
29
+ )
30
+ from openrecruiter.prompts import AGENT_SYSTEM
31
+ from openrecruiter.providers.llm import LLM, LLMError
32
+ from openrecruiter.tools.base import ToolError, ToolRegistry
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ DEFAULT_MAX_STEPS = 8
37
+
38
+
39
+ class PendingApproval(BaseModel):
40
+ """Everything needed to carry on once a human has decided.
41
+
42
+ A plain model rather than a closure, because the decision usually arrives on
43
+ a later HTTP request in a different process. Store it, then hand it back to
44
+ `resume()`.
45
+ """
46
+
47
+ messages: list[dict] = Field(default_factory=list)
48
+ tool_name: str = ""
49
+ tool_call_id: str = ""
50
+ arguments: dict = Field(default_factory=dict)
51
+ #: Calls the model made in the same turn that have not run yet.
52
+ queued: list[dict] = Field(default_factory=list)
53
+ step: int = 0
54
+
55
+
56
+ class Agent:
57
+ """Runs a conversation with tools until the work is done."""
58
+
59
+ def __init__(
60
+ self,
61
+ llm: LLM,
62
+ tools: ToolRegistry,
63
+ system: str = AGENT_SYSTEM,
64
+ max_steps: int = DEFAULT_MAX_STEPS,
65
+ ) -> None:
66
+ self.llm = llm
67
+ self.tools = tools
68
+ self.system = system
69
+ self.max_steps = max_steps
70
+ #: Set when a run stops at an approval gate; None otherwise.
71
+ self.pending: PendingApproval | None = None
72
+
73
+ # ── entry points ─────────────────────────────────────────────────────
74
+
75
+ def run(self, message: str, history: list[dict] | None = None) -> Iterator[Event]:
76
+ """Answer `message`, calling tools as needed."""
77
+ messages = [*(history or []), {"role": "user", "content": message}]
78
+ self.pending = None
79
+ yield from self._loop(messages, step=0)
80
+
81
+ def resume(self, pending: PendingApproval, approved: bool) -> Iterator[Event]:
82
+ """Continue a run that stopped for approval.
83
+
84
+ Declining is not an error: the model is told the user refused and gets to
85
+ respond to that, which is usually more useful than an abandoned turn.
86
+ """
87
+ self.pending = None
88
+ messages = list(pending.messages)
89
+
90
+ if approved:
91
+ result = self._invoke(pending.tool_name, pending.arguments)
92
+ yield result
93
+ messages.append(_tool_message(pending.tool_call_id, result))
94
+ else:
95
+ declined = ToolResult(
96
+ id=pending.tool_call_id,
97
+ name=pending.tool_name,
98
+ error="The user declined this action. It was not performed.",
99
+ )
100
+ yield declined
101
+ messages.append(_tool_message(pending.tool_call_id, declined))
102
+
103
+ queued = [ToolCall(**c) for c in pending.queued]
104
+ stopped = yield from self._run_tools(queued, messages, pending.step)
105
+ if stopped:
106
+ return
107
+ yield from self._loop(messages, step=pending.step + 1)
108
+
109
+ # ── the loop ─────────────────────────────────────────────────────────
110
+
111
+ def _loop(self, messages: list[dict], step: int) -> Iterator[Event]:
112
+ schemas = self.tools.schemas()
113
+
114
+ while step < self.max_steps:
115
+ text_parts: list[str] = []
116
+ calls: list[ToolCall] = []
117
+
118
+ try:
119
+ for event in self.llm.stream(self.system, messages, tools=schemas):
120
+ if isinstance(event, TextDelta):
121
+ text_parts.append(event.text)
122
+ yield event
123
+ else:
124
+ calls.append(event)
125
+ except LLMError as exc:
126
+ log.error("Agent step %d failed: %s", step, exc)
127
+ yield Finished(text="".join(text_parts), stop_reason="error", steps=step, error=str(exc))
128
+ return
129
+
130
+ text = "".join(text_parts)
131
+
132
+ if not calls:
133
+ yield Finished(text=text, stop_reason="end_turn", steps=step)
134
+ return
135
+
136
+ messages.append(_assistant_message(text, calls))
137
+
138
+ stopped = yield from self._run_tools(calls, messages, step)
139
+ if stopped:
140
+ return
141
+ step += 1
142
+
143
+ yield Finished(stop_reason="max_steps", steps=step)
144
+
145
+ def _run_tools(
146
+ self, calls: list[ToolCall], messages: list[dict], step: int
147
+ ) -> Iterator[Event]:
148
+ """Run each call in order. Returns True if the run stopped for approval.
149
+
150
+ Anything queued behind an approval gate is carried into `PendingApproval`
151
+ rather than run — the user has not agreed to those either, and running
152
+ them would make the gate cosmetic.
153
+ """
154
+ for i, call in enumerate(calls):
155
+ if self._needs_approval(call.name):
156
+ self.pending = PendingApproval(
157
+ messages=messages,
158
+ tool_name=call.name,
159
+ tool_call_id=call.id,
160
+ arguments=call.arguments,
161
+ queued=[
162
+ {"id": c.id, "name": c.name, "arguments": c.arguments}
163
+ for c in calls[i + 1 :]
164
+ ],
165
+ step=step,
166
+ )
167
+ yield ApprovalRequired(
168
+ id=call.id,
169
+ name=call.name,
170
+ arguments=call.arguments,
171
+ description=self.tools.get(call.name).description,
172
+ )
173
+ yield Finished(stop_reason="awaiting_approval", steps=step)
174
+ return True
175
+
176
+ # Announced before it runs, so a caller can show what is happening
177
+ # rather than a spinner — a rank over a large pool is not instant.
178
+ yield call
179
+
180
+ result = self._invoke(call.name, call.arguments, call_id=call.id)
181
+ yield result
182
+ messages.append(_tool_message(call.id, result))
183
+
184
+ return False
185
+
186
+ # ── helpers ──────────────────────────────────────────────────────────
187
+
188
+ def _needs_approval(self, name: str) -> bool:
189
+ try:
190
+ return self.tools.get(name).requires_approval
191
+ except ToolError:
192
+ return False
193
+
194
+ def _invoke(self, name: str, arguments: dict, call_id: str = "") -> ToolResult:
195
+ """Call a tool, turning any failure into a result the model can read.
196
+
197
+ A tool raising must not end the turn: the model can often recover — fix
198
+ an argument, try a different tool — if it is told what went wrong.
199
+ """
200
+ try:
201
+ value = self.tools.call(name, arguments)
202
+ except ToolError as exc:
203
+ return ToolResult(id=call_id, name=name, error=str(exc))
204
+ except Exception as exc: # noqa: BLE001 - tools are arbitrary user code
205
+ log.exception("Tool %s raised", name)
206
+ return ToolResult(id=call_id, name=name, error=f"{type(exc).__name__}: {exc}")
207
+ return ToolResult(id=call_id, name=name, result=value)
208
+
209
+
210
+ # ── provider message shapes ──────────────────────────────────────────────
211
+
212
+
213
+ def _assistant_message(text: str, calls: list[ToolCall]) -> dict:
214
+ return {
215
+ "role": "assistant",
216
+ "content": text or None,
217
+ "tool_calls": [
218
+ {
219
+ "id": c.id,
220
+ "type": "function",
221
+ "function": {"name": c.name, "arguments": json.dumps(c.arguments)},
222
+ }
223
+ for c in calls
224
+ ],
225
+ }
226
+
227
+
228
+ def _tool_message(call_id: str, result: ToolResult) -> dict:
229
+ payload = {"error": result.error} if result.error else {"result": result.result}
230
+ return {
231
+ "role": "tool",
232
+ "tool_call_id": call_id,
233
+ "content": json.dumps(payload, default=str),
234
+ }
235
+
236
+
237
+ __all__ = ["Agent", "PendingApproval", "DEFAULT_MAX_STEPS"]