FlyingNunchucks commited on
Commit
6bedc74
·
verified ·
1 Parent(s): 8cb054a

Sync from GitHub via hub-sync

Browse files
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ HF_TOKEN=your_huggingface_token_here
2
+ MODEL_ID=openai/gpt-oss-120b:fastest
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Mendoza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,15 +1,309 @@
1
- ---
2
- title: 07 Tool Using Agent
3
- emoji: 🐢
4
- colorFrom: blue
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- license: mit
12
- short_description: 07-tool-using-agent
13
- ---
14
-
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tool-Using Operations Agent — Structured Tool Router
2
+
3
+ This project demonstrates a controlled tool-using AI agent in which the language model may propose tool calls, but application code retains authority over:
4
+
5
+ - which tools exist
6
+ - which arguments are permitted
7
+ - argument validation
8
+ - tool execution
9
+ - failure handling
10
+ - result normalization
11
+ - audit logging
12
+
13
+ The central architectural principle is:
14
+
15
+ The model proposes. The application validates and executes.
16
+
17
+ ## Project Summary
18
+
19
+ This repository presents a practical example of a structured tool router for an operations-oriented agent. Rather than giving the model unrestricted access to Python, shell, SQL, filesystem, or operating-system execution, the system exposes only a small set of approved tools with explicit schemas and controlled validation.
20
+
21
+ The agent is built with Python, Pydantic, an OpenAI-compatible client for Hugging Face Inference Providers, Gradio, SQLite, and the World Bank Countries API. The application records tool execution metadata in a JSONL audit log while keeping the public interface limited to the current request's tool activity.
22
+
23
+ ## Architecture
24
+
25
+ ```text
26
+ User Request
27
+ ↓
28
+ LLM / Tool Selection
29
+ ↓
30
+ Structured ToolCall
31
+ ↓
32
+ Tool Registry / Allowlist
33
+ ↓
34
+ Argument Validation
35
+ ↓
36
+ Controlled Executor
37
+ ↓
38
+ Approved Tool
39
+ ↓
40
+ Normalized ToolResult
41
+ ↓
42
+ Audit Log
43
+ ↓
44
+ Result Returned to LLM
45
+ ↓
46
+ Final Answer
47
+ ```
48
+
49
+ The LLM never receives arbitrary Python, shell, SQL, filesystem, or operating-system execution capability. All privileged actions are mediated by the application layer before a tool is executed.
50
+
51
+ ## Current Stack
52
+
53
+ - Python
54
+ - Pydantic
55
+ - OpenAI-compatible Python client
56
+ - Hugging Face Inference Providers
57
+ - `openai/gpt-oss-120b:cerebras`
58
+ - Gradio
59
+ - SQLite
60
+ - World Bank Countries API
61
+ - pytest
62
+
63
+ The Hugging Face credential is supplied through the environment variable:
64
+
65
+ ```bash
66
+ HF_TOKEN
67
+ ```
68
+
69
+ ## Approved Tools
70
+
71
+ ### calculator
72
+
73
+ A constrained deterministic arithmetic tool.
74
+
75
+ Important security characteristics:
76
+
77
+ - does not use unrestricted Python `eval()`
78
+ - parses expressions through Python AST
79
+ - permits only approved mathematical operations
80
+
81
+ ### search_inventory
82
+
83
+ A constrained SQLite inventory lookup.
84
+
85
+ Important security characteristics:
86
+
87
+ - does not expose arbitrary SQL execution
88
+ - accepts only approved search parameters
89
+ - uses parameterized SQL
90
+ - initializes demo inventory data when needed
91
+
92
+ ### lookup_country
93
+
94
+ Retrieves structured country information using the World Bank Countries API.
95
+
96
+ Important reliability characteristics:
97
+
98
+ - controlled HTTP timeout
99
+ - HTTP error handling
100
+ - response-shape validation
101
+ - normalized structured result
102
+ - no API key required for the World Bank lookup itself
103
+
104
+ ## Agent Execution Flow
105
+
106
+ The agent runs in a tool loop. The model may propose a tool call, but the application validates the call and executes it through a controlled executor. Tool results are normalized and returned to the model in a structured form, which allows the model to decide whether additional tool calls are needed before delivering a final answer.
107
+
108
+ This design keeps the model largely responsible for reasoning and planning while the application remains responsible for trust boundaries and operational control.
109
+
110
+ ## Multi-Tool Behavior
111
+
112
+ The project supports sequential tool orchestration. A request such as:
113
+
114
+ ```text
115
+ How many Electronics items are in inventory, and what is 347 multiplied by that number?
116
+ ```
117
+
118
+ can be handled in sequence:
119
+
120
+ 1. the model selects `search_inventory`
121
+ 2. it receives a count of 3 Electronics records
122
+ 3. the model selects `calculator`
123
+ 4. it calculates `347 * 3`
124
+ 5. it returns `1041`
125
+
126
+ This is not a hardcoded workflow. The model chooses tools based on the request and the available tool schemas, while the application enforces safety and valid execution.
127
+
128
+ ## Auditability
129
+
130
+ Every executed tool call is recorded with structured metadata:
131
+
132
+ - a call ID is assigned
133
+ - tool arguments are preserved
134
+ - status is recorded as success or error
135
+ - output or error is recorded
136
+ - start and completion timestamps are recorded
137
+ - execution duration is recorded
138
+
139
+ Audit entries are persisted as JSONL in:
140
+
141
+ ```text
142
+ logs/tool_audit.jsonl
143
+ ```
144
+
145
+ The public Gradio interface is privacy-conscious in a portfolio/demo sense:
146
+
147
+ - the complete audit log remains server-side
148
+ - the public UI displays only audit records associated with the current request
149
+ - historical calls from other visitors are not exposed through the current-run audit view
150
+
151
+ This is not presented as a multi-tenant security platform; it is a privacy-conscious demo design intended for a single local or deployed app instance.
152
+
153
+ ## Failure Handling
154
+
155
+ The executor normalizes tool failures into structured results instead of allowing unhandled exceptions to break the agent loop. Examples of handled failure modes include:
156
+
157
+ - unknown or unregistered tool
158
+ - missing required argument
159
+ - unexpected argument
160
+ - wrong argument type
161
+ - division by zero
162
+ - external API failure
163
+
164
+ This keeps the model operating on consistent result objects, even when a tool fails or the dependency is unavailable.
165
+
166
+ ## Repository Structure
167
+
168
+ ```text
169
+ 07-tool-using-agent/
170
+ ├── app.py
171
+ ├── requirements.txt
172
+ ├── .env.example
173
+ ├── data/
174
+ │ └── .gitkeep
175
+ ├── logs/
176
+ │ └── .gitkeep
177
+ ├── src/
178
+ │ ├── __init__.py
179
+ │ ├── agent.py
180
+ │ ├── schemas.py
181
+ │ ├── tool_registry.py
182
+ │ ├── executor.py
183
+ │ ├── audit.py
184
+ │ └── tools/
185
+ │ ├── __init__.py
186
+ │ ├── calculator.py
187
+ │ ├── database.py
188
+ │ └── external_api.py
189
+ └── tests/
190
+ ├── __init__.py
191
+ ├── test_tools.py
192
+ └── test_executor.py
193
+ ```
194
+
195
+ Runtime-generated artifacts such as `operations.db` and `tool_audit.jsonl` are not presented here as tracked repository files.
196
+
197
+ ## Setup
198
+
199
+ ```bash
200
+ git clone https://github.com/wushuchris/07-tool-using-agent.git
201
+ cd 07-tool-using-agent
202
+
203
+ python -m venv .venv
204
+ source .venv/bin/activate
205
+
206
+ pip install -r requirements.txt
207
+ ```
208
+
209
+ Create a local `.env` file with:
210
+
211
+ ```bash
212
+ HF_TOKEN=your_huggingface_token_here
213
+ ```
214
+
215
+ You may also optionally set:
216
+
217
+ ```bash
218
+ MODEL_ID=openai/gpt-oss-120b:cerebras
219
+ ```
220
+
221
+ Notes:
222
+
223
+ - `.env` is gitignored
224
+ - the real token must never be committed
225
+ - the token should be created with only the Hugging Face Inference Provider permissions required to run the model
226
+
227
+ ## Running
228
+
229
+ ```bash
230
+ python app.py
231
+ ```
232
+
233
+ This starts the Gradio interface for the agent.
234
+
235
+ ## Example Requests
236
+
237
+ - `What is 347 multiplied by 29?`
238
+ - `What electronics are currently in inventory?`
239
+ - `What is the capital, region, and income level of Japan?`
240
+ - `How many Electronics items are in inventory, and what is 347 multiplied by that number?`
241
+
242
+ ## Testing
243
+
244
+ ```bash
245
+ pytest -v
246
+ ```
247
+
248
+ The repository currently reports:
249
+
250
+ **15 automated tests currently pass.**
251
+
252
+ The test suite covers:
253
+
254
+ - calculator behavior and restricted execution
255
+ - SQLite inventory search
256
+ - mocked World Bank response handling
257
+ - approved tool execution
258
+ - unregistered tool rejection
259
+ - missing arguments
260
+ - unexpected arguments
261
+ - incorrect argument types
262
+ - normalized tool failures
263
+ - execution timing metadata
264
+
265
+ The tests do not imply the live LLM provider is called as part of the unit test suite.
266
+
267
+ ## Security and Guardrails
268
+
269
+ This project is designed around a narrow, explicit trust boundary:
270
+
271
+ - tool allowlisting
272
+ - constrained tool interfaces
273
+ - schema-based arguments
274
+ - rejection of unknown tools
275
+ - no unrestricted `eval`
276
+ - no arbitrary SQL
277
+ - no shell execution tool
278
+ - environment-based secret management
279
+ - runtime database and audit logs are gitignored
280
+ - public audit display is scoped to the current run
281
+
282
+ This should be understood as a disciplined portfolio/demo design, not as a production-certified security system.
283
+
284
+ ## Design Lessons
285
+
286
+ This project demonstrates several engineering lessons:
287
+
288
+ 1. Tool selection and tool execution should be separate concerns.
289
+ 2. The model should not directly control privileged capabilities.
290
+ 3. Tool calls should use explicit schemas.
291
+ 4. Tool failures should become structured data.
292
+ 5. External dependencies require validation and timeout/error handling.
293
+ 6. Multi-step requests may require sequential tool calls.
294
+ 7. Tool activity should be observable and auditable.
295
+
296
+ ## Deployment
297
+
298
+ The application is designed to be deployable to Hugging Face Spaces with:
299
+
300
+ ```bash
301
+ HF_TOKEN
302
+ ```
303
+
304
+ configured as a Space secret.
305
+
306
+
307
+ ## License
308
+
309
+ This project is distributed under the repository's existing license terms.
app.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List
2
+
3
+ import gradio as gr
4
+
5
+ from src.agent import run_agent
6
+ from src.audit import read_audit_records
7
+ from src.tool_registry import list_tools
8
+
9
+
10
+ def get_run_audit_records(
11
+ call_ids: List[str],
12
+ ) -> List[Dict[str, Any]]:
13
+ """Return audit records belonging only to the current agent run."""
14
+
15
+ if not call_ids:
16
+ return []
17
+
18
+ records = read_audit_records()
19
+
20
+ matching_records = [
21
+ record
22
+ for record in records
23
+ if record.call.call_id in call_ids
24
+ ]
25
+
26
+ return [
27
+ record.model_dump(mode="json")
28
+ for record in matching_records
29
+ ]
30
+
31
+
32
+ def handle_request(
33
+ user_request: str,
34
+ ):
35
+ """
36
+ Run the agent and return user-facing execution details.
37
+ """
38
+
39
+ if not user_request or not user_request.strip():
40
+ return (
41
+ "Please enter a request.",
42
+ [],
43
+ [],
44
+ [],
45
+ )
46
+
47
+ try:
48
+ result = run_agent(
49
+ user_request.strip()
50
+ )
51
+
52
+ tool_calls = [
53
+ call.model_dump(mode="json")
54
+ for call in result.tool_calls
55
+ ]
56
+
57
+ tool_results = [
58
+ tool_result.model_dump(mode="json")
59
+ for tool_result in result.tool_results
60
+ ]
61
+
62
+ call_ids = [
63
+ call.call_id
64
+ for call in result.tool_calls
65
+ ]
66
+
67
+ audit_records = get_run_audit_records(
68
+ call_ids
69
+ )
70
+
71
+ return (
72
+ result.final_answer,
73
+ tool_calls,
74
+ tool_results,
75
+ audit_records,
76
+ )
77
+
78
+ except Exception:
79
+ return (
80
+ (
81
+ "Agent execution failed. "
82
+ "Please review the server logs for details."
83
+ ),
84
+ [],
85
+ [],
86
+ [],
87
+ )
88
+
89
+
90
+ APP_DESCRIPTION = """
91
+ # Tool-Using Operations Agent
92
+
93
+ A controlled AI agent that selects and executes approved tools through a
94
+ validated execution layer.
95
+
96
+ The language model **proposes** tool calls. Application code decides what
97
+ tools exist, validates their arguments, executes them, captures failures,
98
+ and records an audit trail.
99
+
100
+ ### Available tools
101
+
102
+ - **calculator** — deterministic arithmetic
103
+ - **search_inventory** — constrained SQLite inventory search
104
+ - **lookup_country** — structured World Bank country lookup
105
+
106
+ Try a single-tool request or a request that requires multiple tools.
107
+ """
108
+
109
+
110
+ with gr.Blocks(
111
+ title="Tool-Using Operations Agent",
112
+ ) as demo:
113
+
114
+ gr.Markdown(APP_DESCRIPTION)
115
+
116
+ with gr.Row():
117
+ request_box = gr.Textbox(
118
+ label="Request",
119
+ placeholder=(
120
+ "Example: How many Electronics items are "
121
+ "in inventory, and what is 347 multiplied "
122
+ "by that number?"
123
+ ),
124
+ lines=3,
125
+ )
126
+
127
+ run_button = gr.Button(
128
+ "Run Agent",
129
+ variant="primary",
130
+ )
131
+
132
+ gr.Examples(
133
+ examples=[
134
+ [
135
+ "What is 347 multiplied by 29?"
136
+ ],
137
+ [
138
+ "What electronics are currently in inventory?"
139
+ ],
140
+ [
141
+ "What is the capital, region, and income level of Japan?"
142
+ ],
143
+ [
144
+ (
145
+ "How many Electronics items are in inventory, "
146
+ "and what is 347 multiplied by that number?"
147
+ )
148
+ ],
149
+ ],
150
+ inputs=request_box,
151
+ )
152
+
153
+ gr.Markdown("## Agent Response")
154
+
155
+ final_answer = gr.Textbox(
156
+ label="Final Answer",
157
+ lines=5,
158
+ interactive=False,
159
+ )
160
+
161
+ with gr.Row():
162
+ tool_calls_output = gr.JSON(
163
+ label="Tool Calls",
164
+ )
165
+
166
+ tool_results_output = gr.JSON(
167
+ label="Tool Results",
168
+ )
169
+
170
+ gr.Markdown(
171
+ "## Current Run Audit Trail\n"
172
+ "Only tool executions generated by this request are shown."
173
+ )
174
+
175
+ audit_output = gr.JSON(
176
+ label="Audit Records",
177
+ )
178
+
179
+ run_button.click(
180
+ fn=handle_request,
181
+ inputs=request_box,
182
+ outputs=[
183
+ final_answer,
184
+ tool_calls_output,
185
+ tool_results_output,
186
+ audit_output,
187
+ ],
188
+ )
189
+
190
+
191
+ if __name__ == "__main__":
192
+ demo.launch()
data/.gitkeep ADDED
File without changes
logs/.gitkeep ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=5.0,<6.0
2
+ openai>=1.0,<2.0
3
+ pydantic>=2.0,<3.0
4
+ python-dotenv>=1.0,<2.0
5
+ requests>=2.31,<3.0
6
+ pytest>=8.0,<9.0
src/__init__.py ADDED
File without changes
src/agent.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, List
4
+
5
+ from dotenv import load_dotenv
6
+ from openai import OpenAI
7
+
8
+ from src.audit import write_audit_record
9
+ from src.executor import execute_tool
10
+ from src.schemas import AgentRunResult, ToolCall
11
+ from src.tool_registry import get_tool_definitions
12
+
13
+
14
+ load_dotenv(dotenv_path=".env")
15
+
16
+
17
+ HF_BASE_URL = "https://router.huggingface.co/v1"
18
+
19
+ DEFAULT_MODEL = "openai/gpt-oss-120b:cerebras"
20
+
21
+ SYSTEM_PROMPT = """
22
+ You are a controlled tool-using operations agent.
23
+
24
+ You have access only to the tools explicitly provided to you.
25
+
26
+ Rules:
27
+ 1. Use tools when they are needed to answer the user's request accurately.
28
+ 2. Do not claim that a tool was used unless you actually called it.
29
+ 3. Never invent tool results.
30
+ 4. Do not invent tools that are not available.
31
+ 5. Use the calculator for arithmetic rather than calculating mentally.
32
+ 6. Use search_inventory for questions about the inventory database.
33
+ 7. Use lookup_country for structured country information.
34
+ 8. You may call multiple tools when the user's request requires them.
35
+ 9. After receiving tool results, use those results to construct your answer.
36
+ 10. If a tool returns an error, do not hide the failure.
37
+ 11. Do not expose internal chain-of-thought. Provide concise conclusions only.
38
+ """
39
+
40
+
41
+ def get_client() -> OpenAI:
42
+ """Create the Hugging Face OpenAI-compatible client."""
43
+
44
+ token = os.getenv("HF_TOKEN")
45
+
46
+ if not token:
47
+ raise RuntimeError(
48
+ "HF_TOKEN is not configured. "
49
+ "Set it as an environment variable before running the agent."
50
+ )
51
+
52
+ return OpenAI(
53
+ base_url=HF_BASE_URL,
54
+ api_key=token,
55
+ )
56
+
57
+
58
+ def run_agent(
59
+ user_request: str,
60
+ model: str | None = None,
61
+ max_tool_rounds: int = 5,
62
+ ) -> AgentRunResult:
63
+ """
64
+ Run the tool-using agent.
65
+
66
+ The model may request approved tools. Every requested tool is passed
67
+ through the controlled executor and written to the audit log.
68
+ """
69
+
70
+ if not isinstance(user_request, str):
71
+ raise TypeError("user_request must be a string.")
72
+
73
+ user_request = user_request.strip()
74
+
75
+ if not user_request:
76
+ raise ValueError("user_request cannot be empty.")
77
+
78
+ if max_tool_rounds < 1:
79
+ raise ValueError(
80
+ "max_tool_rounds must be at least 1."
81
+ )
82
+
83
+ client = get_client()
84
+
85
+ selected_model = (
86
+ model
87
+ or os.getenv("MODEL_ID")
88
+ or DEFAULT_MODEL
89
+ )
90
+
91
+ tools = get_tool_definitions()
92
+
93
+ messages: List[Dict[str, Any]] = [
94
+ {
95
+ "role": "system",
96
+ "content": SYSTEM_PROMPT,
97
+ },
98
+ {
99
+ "role": "user",
100
+ "content": user_request,
101
+ },
102
+ ]
103
+
104
+ executed_calls: List[ToolCall] = []
105
+ executed_results = []
106
+
107
+ for _ in range(max_tool_rounds):
108
+ response = client.chat.completions.create(
109
+ model=selected_model,
110
+ messages=messages,
111
+ tools=tools,
112
+ tool_choice="auto",
113
+ reasoning_effort="low",
114
+ )
115
+
116
+ message = response.choices[0].message
117
+
118
+ assistant_message: Dict[str, Any] = {
119
+ "role": "assistant",
120
+ "content": message.content,
121
+ }
122
+
123
+ if message.tool_calls:
124
+ assistant_message["tool_calls"] = [
125
+ tool_call.model_dump()
126
+ for tool_call in message.tool_calls
127
+ ]
128
+
129
+ messages.append(assistant_message)
130
+
131
+ if not message.tool_calls:
132
+ final_answer = (
133
+ message.content
134
+ or "The agent returned no final answer."
135
+ )
136
+
137
+ return AgentRunResult(
138
+ user_request=user_request,
139
+ final_answer=final_answer,
140
+ tool_calls=executed_calls,
141
+ tool_results=executed_results,
142
+ )
143
+
144
+ for model_tool_call in message.tool_calls:
145
+ tool_name = model_tool_call.function.name
146
+
147
+ try:
148
+ arguments = json.loads(
149
+ model_tool_call.function.arguments
150
+ )
151
+
152
+ if not isinstance(arguments, dict):
153
+ raise ValueError(
154
+ "Tool arguments must decode to an object."
155
+ )
156
+
157
+ except (
158
+ json.JSONDecodeError,
159
+ ValueError,
160
+ ) as exc:
161
+ arguments = {
162
+ "__invalid_arguments__": (
163
+ model_tool_call.function.arguments
164
+ )
165
+ }
166
+
167
+ call = ToolCall(
168
+ call_id=model_tool_call.id,
169
+ tool_name=tool_name,
170
+ arguments=arguments,
171
+ )
172
+
173
+ result = execute_tool(call)
174
+
175
+ write_audit_record(
176
+ call=call,
177
+ result=result,
178
+ )
179
+
180
+ executed_calls.append(call)
181
+ executed_results.append(result)
182
+
183
+ tool_payload = {
184
+ "status": result.status,
185
+ "output": result.output,
186
+ "error": result.error,
187
+ }
188
+
189
+ messages.append(
190
+ {
191
+ "role": "tool",
192
+ "tool_call_id": model_tool_call.id,
193
+ "content": json.dumps(
194
+ tool_payload,
195
+ default=str,
196
+ ),
197
+ }
198
+ )
199
+
200
+ return AgentRunResult(
201
+ user_request=user_request,
202
+ final_answer=(
203
+ "The agent reached the maximum number "
204
+ "of tool-execution rounds before producing "
205
+ "a final answer."
206
+ ),
207
+ tool_calls=executed_calls,
208
+ tool_results=executed_results,
209
+ )
src/audit.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ from src.schemas import AuditRecord, ToolCall, ToolResult
6
+
7
+
8
+ AUDIT_LOG_PATH = Path("logs/tool_audit.jsonl")
9
+
10
+
11
+ def write_audit_record(
12
+ call: ToolCall,
13
+ result: ToolResult,
14
+ ) -> AuditRecord:
15
+ """
16
+ Write one tool execution record to the JSONL audit log.
17
+ """
18
+
19
+ AUDIT_LOG_PATH.parent.mkdir(
20
+ parents=True,
21
+ exist_ok=True,
22
+ )
23
+
24
+ record = AuditRecord(
25
+ call=call,
26
+ result=result,
27
+ )
28
+
29
+ with AUDIT_LOG_PATH.open(
30
+ "a",
31
+ encoding="utf-8",
32
+ ) as file:
33
+ file.write(
34
+ record.model_dump_json()
35
+ + "\n"
36
+ )
37
+
38
+ return record
39
+
40
+
41
+ def read_audit_records() -> List[AuditRecord]:
42
+ """
43
+ Read all valid audit records from the JSONL log.
44
+ """
45
+
46
+ if not AUDIT_LOG_PATH.exists():
47
+ return []
48
+
49
+ records: List[AuditRecord] = []
50
+
51
+ with AUDIT_LOG_PATH.open(
52
+ "r",
53
+ encoding="utf-8",
54
+ ) as file:
55
+ for line in file:
56
+ line = line.strip()
57
+
58
+ if not line:
59
+ continue
60
+
61
+ try:
62
+ payload = json.loads(line)
63
+ records.append(
64
+ AuditRecord.model_validate(
65
+ payload
66
+ )
67
+ )
68
+ except (
69
+ json.JSONDecodeError,
70
+ ValueError,
71
+ ):
72
+ # Ignore malformed lines rather than
73
+ # crashing the whole audit reader.
74
+ continue
75
+
76
+ return records
77
+
78
+
79
+ def clear_audit_log() -> None:
80
+ """
81
+ Delete the audit log if it exists.
82
+
83
+ Primarily useful for tests and demo resets.
84
+ """
85
+
86
+ if AUDIT_LOG_PATH.exists():
87
+ AUDIT_LOG_PATH.unlink()
src/executor.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from time import perf_counter
3
+ from typing import Any, Dict
4
+
5
+ from src.schemas import ToolCall, ToolResult
6
+ from src.tool_registry import TOOL_REGISTRY, get_tool
7
+
8
+
9
+ def _validate_arguments(
10
+ tool_name: str,
11
+ arguments: Dict[str, Any],
12
+ ) -> None:
13
+ """
14
+ Validate tool arguments against the registry's parameter schema.
15
+
16
+ This is intentionally lightweight and supports the schema features
17
+ used by the tools in this project.
18
+ """
19
+
20
+ if tool_name not in TOOL_REGISTRY:
21
+ raise ValueError(
22
+ f"Tool '{tool_name}' is not registered."
23
+ )
24
+
25
+ schema = TOOL_REGISTRY[tool_name]["parameters"]
26
+
27
+ properties = schema.get("properties", {})
28
+ required = schema.get("required", [])
29
+ allow_additional = schema.get(
30
+ "additionalProperties",
31
+ True,
32
+ )
33
+
34
+ for required_argument in required:
35
+ if required_argument not in arguments:
36
+ raise ValueError(
37
+ f"Missing required argument "
38
+ f"'{required_argument}' for tool "
39
+ f"'{tool_name}'."
40
+ )
41
+
42
+ if not allow_additional:
43
+ unknown_arguments = (
44
+ set(arguments.keys()) - set(properties.keys())
45
+ )
46
+
47
+ if unknown_arguments:
48
+ unknown = ", ".join(
49
+ sorted(unknown_arguments)
50
+ )
51
+
52
+ raise ValueError(
53
+ f"Unexpected argument(s) for tool "
54
+ f"'{tool_name}': {unknown}"
55
+ )
56
+
57
+ python_types = {
58
+ "string": str,
59
+ "integer": int,
60
+ "number": (int, float),
61
+ "boolean": bool,
62
+ "object": dict,
63
+ "array": list,
64
+ }
65
+
66
+ for argument_name, argument_value in arguments.items():
67
+ parameter_schema = properties.get(argument_name)
68
+
69
+ if not parameter_schema:
70
+ continue
71
+
72
+ expected_type_name = parameter_schema.get("type")
73
+
74
+ if not expected_type_name:
75
+ continue
76
+
77
+ expected_python_type = python_types.get(
78
+ expected_type_name
79
+ )
80
+
81
+ if expected_python_type is None:
82
+ continue
83
+
84
+ if not isinstance(
85
+ argument_value,
86
+ expected_python_type,
87
+ ):
88
+ raise TypeError(
89
+ f"Argument '{argument_name}' for tool "
90
+ f"'{tool_name}' must be of type "
91
+ f"'{expected_type_name}'."
92
+ )
93
+
94
+
95
+ def execute_tool(call: ToolCall) -> ToolResult:
96
+ """
97
+ Validate and execute a tool call.
98
+
99
+ All outcomes are normalized into ToolResult objects so callers do not
100
+ need separate handling for every underlying tool.
101
+ """
102
+
103
+ started_at = datetime.now(timezone.utc)
104
+ timer_start = perf_counter()
105
+
106
+ try:
107
+ _validate_arguments(
108
+ tool_name=call.tool_name,
109
+ arguments=call.arguments,
110
+ )
111
+
112
+ tool = get_tool(call.tool_name)
113
+
114
+ output = tool(**call.arguments)
115
+
116
+ status = "success"
117
+ error = None
118
+
119
+ except Exception as exc:
120
+ output = None
121
+ status = "error"
122
+ error = f"{type(exc).__name__}: {exc}"
123
+
124
+ timer_end = perf_counter()
125
+ completed_at = datetime.now(timezone.utc)
126
+
127
+ duration_ms = (
128
+ timer_end - timer_start
129
+ ) * 1000
130
+
131
+ return ToolResult(
132
+ call_id=call.call_id,
133
+ tool_name=call.tool_name,
134
+ status=status,
135
+ output=output,
136
+ error=error,
137
+ started_at=started_at,
138
+ completed_at=completed_at,
139
+ duration_ms=round(duration_ms, 3),
140
+ )
src/schemas.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from typing import Any, Dict, List, Literal, Optional
3
+ from uuid import uuid4
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ToolCall(BaseModel):
9
+ """A validated request to execute one approved tool."""
10
+
11
+ call_id: str = Field(default_factory=lambda: str(uuid4()))
12
+ tool_name: str
13
+ arguments: Dict[str, Any] = Field(default_factory=dict)
14
+
15
+
16
+ class ToolResult(BaseModel):
17
+ """Normalized result returned by every tool."""
18
+
19
+ call_id: str
20
+ tool_name: str
21
+ status: Literal["success", "error"]
22
+ output: Optional[Any] = None
23
+ error: Optional[str] = None
24
+ started_at: datetime
25
+ completed_at: datetime
26
+ duration_ms: float
27
+
28
+
29
+ class AuditRecord(BaseModel):
30
+ """Persistent record of one tool execution."""
31
+
32
+ timestamp: datetime = Field(
33
+ default_factory=lambda: datetime.now(timezone.utc)
34
+ )
35
+ call: ToolCall
36
+ result: ToolResult
37
+
38
+
39
+ class AgentRunResult(BaseModel):
40
+ """Top-level result returned after an agent run."""
41
+
42
+ user_request: str
43
+ final_answer: str
44
+ tool_calls: List[ToolCall] = Field(default_factory=list)
45
+ tool_results: List[ToolResult] = Field(default_factory=list)
src/tool_registry.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Callable, Dict
2
+
3
+ from src.tools.calculator import calculator
4
+ from src.tools.database import search_inventory
5
+ from src.tools.external_api import lookup_country
6
+
7
+
8
+ ToolFunction = Callable[..., Dict[str, Any]]
9
+
10
+
11
+ TOOL_REGISTRY: Dict[str, Dict[str, Any]] = {
12
+ "calculator": {
13
+ "function": calculator,
14
+ "description": (
15
+ "Safely evaluate a mathematical expression. "
16
+ "Use this tool for arithmetic and calculations."
17
+ ),
18
+ "parameters": {
19
+ "type": "object",
20
+ "properties": {
21
+ "expression": {
22
+ "type": "string",
23
+ "description": (
24
+ "A mathematical expression such as "
25
+ "'125 * 4' or '(100 / 4) + 7'."
26
+ ),
27
+ }
28
+ },
29
+ "required": ["expression"],
30
+ "additionalProperties": False,
31
+ },
32
+ },
33
+
34
+ "search_inventory": {
35
+ "function": search_inventory,
36
+ "description": (
37
+ "Search the local inventory database by item name, "
38
+ "category, or both."
39
+ ),
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": {
43
+ "item_name": {
44
+ "type": "string",
45
+ "description": (
46
+ "Optional item name or partial item name."
47
+ ),
48
+ },
49
+ "category": {
50
+ "type": "string",
51
+ "description": (
52
+ "Optional exact inventory category such as "
53
+ "'Electronics', 'Furniture', or "
54
+ "'Office Supplies'."
55
+ ),
56
+ },
57
+ },
58
+ "additionalProperties": False,
59
+ },
60
+ },
61
+
62
+ "lookup_country": {
63
+ "function": lookup_country,
64
+ "description": (
65
+ "Look up structured information about a country "
66
+ "using the World Bank Countries API."
67
+ ),
68
+ "parameters": {
69
+ "type": "object",
70
+ "properties": {
71
+ "country_name": {
72
+ "type": "string",
73
+ "description": (
74
+ "The country name to look up, such as "
75
+ "'Japan' or 'Canada'."
76
+ ),
77
+ }
78
+ },
79
+ "required": ["country_name"],
80
+ "additionalProperties": False,
81
+ },
82
+ },
83
+ }
84
+
85
+
86
+ def get_tool(name: str) -> ToolFunction:
87
+ """Return an approved tool function by name."""
88
+
89
+ if name not in TOOL_REGISTRY:
90
+ raise ValueError(
91
+ f"Tool '{name}' is not registered."
92
+ )
93
+
94
+ return TOOL_REGISTRY[name]["function"]
95
+
96
+
97
+ def get_tool_definitions() -> list[Dict[str, Any]]:
98
+ """
99
+ Return tool definitions in OpenAI-compatible function-call format.
100
+ """
101
+
102
+ definitions = []
103
+
104
+ for name, config in TOOL_REGISTRY.items():
105
+ definitions.append(
106
+ {
107
+ "type": "function",
108
+ "function": {
109
+ "name": name,
110
+ "description": config["description"],
111
+ "parameters": config["parameters"],
112
+ },
113
+ }
114
+ )
115
+
116
+ return definitions
117
+
118
+
119
+ def list_tools() -> list[str]:
120
+ """Return the names of all approved tools."""
121
+
122
+ return sorted(TOOL_REGISTRY.keys())
src/tools/__init__.py ADDED
File without changes
src/tools/calculator.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import operator
3
+ from typing import Any, Dict
4
+
5
+
6
+ _ALLOWED_OPERATORS = {
7
+ ast.Add: operator.add,
8
+ ast.Sub: operator.sub,
9
+ ast.Mult: operator.mul,
10
+ ast.Div: operator.truediv,
11
+ ast.FloorDiv: operator.floordiv,
12
+ ast.Mod: operator.mod,
13
+ ast.Pow: operator.pow,
14
+ ast.USub: operator.neg,
15
+ ast.UAdd: operator.pos,
16
+ }
17
+
18
+
19
+ def _evaluate(node: ast.AST) -> float:
20
+ if isinstance(node, ast.Expression):
21
+ return _evaluate(node.body)
22
+
23
+ if isinstance(node, ast.Constant):
24
+ if isinstance(node.value, (int, float)):
25
+ return node.value
26
+ raise ValueError("Only numeric constants are allowed.")
27
+
28
+ if isinstance(node, ast.BinOp):
29
+ operator_type = type(node.op)
30
+
31
+ if operator_type not in _ALLOWED_OPERATORS:
32
+ raise ValueError(
33
+ f"Operator {operator_type.__name__} is not allowed."
34
+ )
35
+
36
+ left = _evaluate(node.left)
37
+ right = _evaluate(node.right)
38
+
39
+ return _ALLOWED_OPERATORS[operator_type](left, right)
40
+
41
+ if isinstance(node, ast.UnaryOp):
42
+ operator_type = type(node.op)
43
+
44
+ if operator_type not in _ALLOWED_OPERATORS:
45
+ raise ValueError(
46
+ f"Operator {operator_type.__name__} is not allowed."
47
+ )
48
+
49
+ value = _evaluate(node.operand)
50
+
51
+ return _ALLOWED_OPERATORS[operator_type](value)
52
+
53
+ raise ValueError(
54
+ f"Unsupported expression element: {type(node).__name__}"
55
+ )
56
+
57
+
58
+ def calculator(expression: str) -> Dict[str, Any]:
59
+ """
60
+ Safely evaluate a basic mathematical expression.
61
+
62
+ Supported operations:
63
+ +, -, *, /, //, %, **, unary +, unary -
64
+ """
65
+
66
+ if not isinstance(expression, str):
67
+ raise TypeError("expression must be a string.")
68
+
69
+ expression = expression.strip()
70
+
71
+ if not expression:
72
+ raise ValueError("expression cannot be empty.")
73
+
74
+ if len(expression) > 200:
75
+ raise ValueError("expression is too long.")
76
+
77
+ try:
78
+ parsed = ast.parse(expression, mode="eval")
79
+ result = _evaluate(parsed)
80
+ except ZeroDivisionError as exc:
81
+ raise ValueError("Division by zero is not allowed.") from exc
82
+ except SyntaxError as exc:
83
+ raise ValueError("Invalid mathematical expression.") from exc
84
+
85
+ return {
86
+ "expression": expression,
87
+ "result": result,
88
+ }
src/tools/database.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List
4
+
5
+
6
+ DB_PATH = Path("data/operations.db")
7
+
8
+
9
+ def initialize_database() -> None:
10
+ """Create the demo database and seed it if needed."""
11
+
12
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
13
+
14
+ with sqlite3.connect(DB_PATH) as conn:
15
+ cursor = conn.cursor()
16
+
17
+ cursor.execute(
18
+ """
19
+ CREATE TABLE IF NOT EXISTS inventory (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ item_name TEXT NOT NULL,
22
+ category TEXT NOT NULL,
23
+ quantity INTEGER NOT NULL,
24
+ unit_price REAL NOT NULL
25
+ )
26
+ """
27
+ )
28
+
29
+ cursor.execute("SELECT COUNT(*) FROM inventory")
30
+ row_count = cursor.fetchone()[0]
31
+
32
+ if row_count == 0:
33
+ sample_rows = [
34
+ ("Laptop", "Electronics", 12, 1299.99),
35
+ ("Monitor", "Electronics", 25, 349.50),
36
+ ("Keyboard", "Electronics", 40, 89.99),
37
+ ("Office Chair", "Furniture", 8, 499.00),
38
+ ("Desk", "Furniture", 6, 799.00),
39
+ ("Notebook", "Office Supplies", 120, 4.99),
40
+ ("Pen Set", "Office Supplies", 75, 12.50),
41
+ ]
42
+
43
+ cursor.executemany(
44
+ """
45
+ INSERT INTO inventory (
46
+ item_name,
47
+ category,
48
+ quantity,
49
+ unit_price
50
+ )
51
+ VALUES (?, ?, ?, ?)
52
+ """,
53
+ sample_rows,
54
+ )
55
+
56
+ conn.commit()
57
+
58
+
59
+ def search_inventory(
60
+ item_name: str = "",
61
+ category: str = "",
62
+ ) -> Dict[str, Any]:
63
+ """
64
+ Search inventory using optional item name and category filters.
65
+
66
+ This tool exposes a constrained query interface rather than allowing
67
+ arbitrary SQL execution.
68
+ """
69
+
70
+ initialize_database()
71
+
72
+ query = """
73
+ SELECT
74
+ id,
75
+ item_name,
76
+ category,
77
+ quantity,
78
+ unit_price
79
+ FROM inventory
80
+ WHERE 1 = 1
81
+ """
82
+
83
+ parameters: List[Any] = []
84
+
85
+ if item_name:
86
+ query += " AND LOWER(item_name) LIKE LOWER(?)"
87
+ parameters.append(f"%{item_name.strip()}%")
88
+
89
+ if category:
90
+ query += " AND LOWER(category) = LOWER(?)"
91
+ parameters.append(category.strip())
92
+
93
+ query += " ORDER BY item_name"
94
+
95
+ with sqlite3.connect(DB_PATH) as conn:
96
+ conn.row_factory = sqlite3.Row
97
+ cursor = conn.execute(query, parameters)
98
+ rows = [dict(row) for row in cursor.fetchall()]
99
+
100
+ return {
101
+ "filters": {
102
+ "item_name": item_name,
103
+ "category": category,
104
+ },
105
+ "count": len(rows),
106
+ "items": rows,
107
+ }
src/tools/external_api.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List
2
+
3
+ import requests
4
+
5
+
6
+ BASE_URL = "https://api.worldbank.org/v2/country"
7
+
8
+
9
+ def lookup_country(country_name: str) -> Dict[str, Any]:
10
+ """
11
+ Retrieve basic country information from the World Bank Countries API.
12
+
13
+ This tool demonstrates controlled access to an external HTTP API
14
+ without requiring an API key.
15
+ """
16
+
17
+ if not isinstance(country_name, str):
18
+ raise TypeError("country_name must be a string.")
19
+
20
+ country_name = country_name.strip()
21
+
22
+ if not country_name:
23
+ raise ValueError("country_name cannot be empty.")
24
+
25
+ if len(country_name) > 100:
26
+ raise ValueError("country_name is too long.")
27
+
28
+ try:
29
+ response = requests.get(
30
+ BASE_URL,
31
+ params={
32
+ "format": "json",
33
+ "per_page": 400,
34
+ },
35
+ timeout=10,
36
+ )
37
+
38
+ response.raise_for_status()
39
+
40
+ except requests.Timeout as exc:
41
+ raise RuntimeError(
42
+ "World Bank API request timed out."
43
+ ) from exc
44
+
45
+ except requests.RequestException as exc:
46
+ raise RuntimeError(
47
+ f"World Bank API request failed: {exc}"
48
+ ) from exc
49
+
50
+ try:
51
+ payload = response.json()
52
+ except ValueError as exc:
53
+ raise RuntimeError(
54
+ "World Bank API returned invalid JSON."
55
+ ) from exc
56
+
57
+ if (
58
+ not isinstance(payload, list)
59
+ or len(payload) < 2
60
+ or not isinstance(payload[1], list)
61
+ ):
62
+ raise RuntimeError(
63
+ "World Bank API returned an unexpected response."
64
+ )
65
+
66
+ countries: List[Dict[str, Any]] = payload[1]
67
+
68
+ normalized_query = country_name.casefold()
69
+
70
+ exact_matches = [
71
+ country
72
+ for country in countries
73
+ if country.get("name", "").casefold() == normalized_query
74
+ ]
75
+
76
+ if exact_matches:
77
+ country = exact_matches[0]
78
+ else:
79
+ partial_matches = [
80
+ country
81
+ for country in countries
82
+ if normalized_query in country.get("name", "").casefold()
83
+ ]
84
+
85
+ if len(partial_matches) == 1:
86
+ country = partial_matches[0]
87
+ elif len(partial_matches) > 1:
88
+ names = [
89
+ match.get("name")
90
+ for match in partial_matches[:10]
91
+ ]
92
+
93
+ raise ValueError(
94
+ f"Country name '{country_name}' is ambiguous. "
95
+ f"Possible matches: {names}"
96
+ )
97
+ else:
98
+ raise ValueError(
99
+ f"No country found matching '{country_name}'."
100
+ )
101
+
102
+ region = country.get("region") or {}
103
+ income_level = country.get("incomeLevel") or {}
104
+ lending_type = country.get("lendingType") or {}
105
+
106
+ return {
107
+ "query": country_name,
108
+ "name": country.get("name"),
109
+ "iso2_code": country.get("iso2Code"),
110
+ "iso3_code": country.get("id"),
111
+ "capital": country.get("capitalCity"),
112
+ "region": region.get("value"),
113
+ "income_level": income_level.get("value"),
114
+ "lending_type": lending_type.get("value"),
115
+ "longitude": country.get("longitude"),
116
+ "latitude": country.get("latitude"),
117
+ }
tests/__init__.py ADDED
File without changes
tests/test_executor.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.executor import execute_tool
2
+ from src.schemas import ToolCall
3
+
4
+
5
+ def test_executor_runs_approved_tool():
6
+ call = ToolCall(
7
+ tool_name="calculator",
8
+ arguments={
9
+ "expression": "12 * 4",
10
+ },
11
+ )
12
+
13
+ result = execute_tool(call)
14
+
15
+ assert result.status == "success"
16
+ assert result.output is not None
17
+ assert result.output["result"] == 48
18
+ assert result.error is None
19
+ assert result.call_id == call.call_id
20
+ assert result.tool_name == "calculator"
21
+
22
+
23
+ def test_executor_rejects_unregistered_tool():
24
+ call = ToolCall(
25
+ tool_name="run_shell_command",
26
+ arguments={
27
+ "command": "whoami",
28
+ },
29
+ )
30
+
31
+ result = execute_tool(call)
32
+
33
+ assert result.status == "error"
34
+ assert result.output is None
35
+ assert result.error is not None
36
+ assert "not registered" in result.error
37
+
38
+
39
+ def test_executor_rejects_missing_required_argument():
40
+ call = ToolCall(
41
+ tool_name="calculator",
42
+ arguments={},
43
+ )
44
+
45
+ result = execute_tool(call)
46
+
47
+ assert result.status == "error"
48
+ assert result.output is None
49
+ assert result.error is not None
50
+ assert "Missing required argument" in result.error
51
+ assert "expression" in result.error
52
+
53
+
54
+ def test_executor_rejects_unexpected_argument():
55
+ call = ToolCall(
56
+ tool_name="calculator",
57
+ arguments={
58
+ "expression": "10 + 5",
59
+ "unexpected": "value",
60
+ },
61
+ )
62
+
63
+ result = execute_tool(call)
64
+
65
+ assert result.status == "error"
66
+ assert result.output is None
67
+ assert result.error is not None
68
+ assert "Unexpected argument" in result.error
69
+ assert "unexpected" in result.error
70
+
71
+
72
+ def test_executor_rejects_wrong_argument_type():
73
+ call = ToolCall(
74
+ tool_name="calculator",
75
+ arguments={
76
+ "expression": 123,
77
+ },
78
+ )
79
+
80
+ result = execute_tool(call)
81
+
82
+ assert result.status == "error"
83
+ assert result.output is None
84
+ assert result.error is not None
85
+ assert "must be of type 'string'" in result.error
86
+
87
+
88
+ def test_executor_normalizes_tool_failure():
89
+ call = ToolCall(
90
+ tool_name="calculator",
91
+ arguments={
92
+ "expression": "10 / 0",
93
+ },
94
+ )
95
+
96
+ result = execute_tool(call)
97
+
98
+ assert result.status == "error"
99
+ assert result.output is None
100
+ assert result.error is not None
101
+ assert "Division by zero" in result.error
102
+
103
+
104
+ def test_executor_records_timing_information():
105
+ call = ToolCall(
106
+ tool_name="calculator",
107
+ arguments={
108
+ "expression": "2 + 2",
109
+ },
110
+ )
111
+
112
+ result = execute_tool(call)
113
+
114
+ assert result.started_at is not None
115
+ assert result.completed_at is not None
116
+ assert result.completed_at >= result.started_at
117
+ assert result.duration_ms >= 0
tests/test_tools.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ import src.tools.database as database_tool
4
+ import src.tools.external_api as external_api_tool
5
+ from src.tools.calculator import calculator
6
+
7
+
8
+ def test_calculator_basic_math():
9
+ result = calculator("25 * 8")
10
+
11
+ assert result["expression"] == "25 * 8"
12
+ assert result["result"] == 200
13
+
14
+
15
+ def test_calculator_respects_operator_precedence():
16
+ result = calculator("10 + 5 * 2")
17
+
18
+ assert result["result"] == 20
19
+
20
+
21
+ def test_calculator_rejects_code_execution():
22
+ with pytest.raises(ValueError):
23
+ calculator(
24
+ "__import__('os').system('echo hacked')"
25
+ )
26
+
27
+
28
+ def test_calculator_rejects_division_by_zero():
29
+ with pytest.raises(
30
+ ValueError,
31
+ match="Division by zero",
32
+ ):
33
+ calculator("10 / 0")
34
+
35
+
36
+ def test_inventory_category_search(
37
+ tmp_path,
38
+ monkeypatch,
39
+ ):
40
+ test_db = tmp_path / "operations.db"
41
+
42
+ monkeypatch.setattr(
43
+ database_tool,
44
+ "DB_PATH",
45
+ test_db,
46
+ )
47
+
48
+ result = database_tool.search_inventory(
49
+ category="Electronics"
50
+ )
51
+
52
+ assert result["count"] == 3
53
+
54
+ names = {
55
+ item["item_name"]
56
+ for item in result["items"]
57
+ }
58
+
59
+ assert names == {
60
+ "Laptop",
61
+ "Monitor",
62
+ "Keyboard",
63
+ }
64
+
65
+
66
+ def test_inventory_item_search(
67
+ tmp_path,
68
+ monkeypatch,
69
+ ):
70
+ test_db = tmp_path / "operations.db"
71
+
72
+ monkeypatch.setattr(
73
+ database_tool,
74
+ "DB_PATH",
75
+ test_db,
76
+ )
77
+
78
+ result = database_tool.search_inventory(
79
+ item_name="chair"
80
+ )
81
+
82
+ assert result["count"] == 1
83
+ assert (
84
+ result["items"][0]["item_name"]
85
+ == "Office Chair"
86
+ )
87
+
88
+
89
+ class FakeResponse:
90
+ def __init__(
91
+ self,
92
+ payload,
93
+ status_code=200,
94
+ ):
95
+ self._payload = payload
96
+ self.status_code = status_code
97
+
98
+ def raise_for_status(self):
99
+ return None
100
+
101
+ def json(self):
102
+ return self._payload
103
+
104
+
105
+ def _world_bank_payload():
106
+ return [
107
+ {
108
+ "page": 1,
109
+ "pages": 1,
110
+ "total": 1,
111
+ },
112
+ [
113
+ {
114
+ "id": "JPN",
115
+ "iso2Code": "JP",
116
+ "name": "Japan",
117
+ "region": {
118
+ "value": "East Asia & Pacific"
119
+ },
120
+ "incomeLevel": {
121
+ "value": "High income"
122
+ },
123
+ "lendingType": {
124
+ "value": "Not classified"
125
+ },
126
+ "capitalCity": "Tokyo",
127
+ "longitude": "139.77",
128
+ "latitude": "35.67",
129
+ }
130
+ ],
131
+ ]
132
+
133
+
134
+ def test_country_lookup_success(
135
+ monkeypatch,
136
+ ):
137
+ def fake_get(*args, **kwargs):
138
+ return FakeResponse(
139
+ _world_bank_payload()
140
+ )
141
+
142
+ monkeypatch.setattr(
143
+ external_api_tool.requests,
144
+ "get",
145
+ fake_get,
146
+ )
147
+
148
+ result = external_api_tool.lookup_country(
149
+ "Japan"
150
+ )
151
+
152
+ assert result["name"] == "Japan"
153
+ assert result["iso2_code"] == "JP"
154
+ assert result["iso3_code"] == "JPN"
155
+ assert result["capital"] == "Tokyo"
156
+ assert (
157
+ result["region"]
158
+ == "East Asia & Pacific"
159
+ )
160
+ assert (
161
+ result["income_level"]
162
+ == "High income"
163
+ )
164
+
165
+
166
+ def test_country_lookup_not_found(
167
+ monkeypatch,
168
+ ):
169
+ def fake_get(*args, **kwargs):
170
+ return FakeResponse(
171
+ [
172
+ {
173
+ "page": 1,
174
+ "pages": 1,
175
+ "total": 0,
176
+ },
177
+ [],
178
+ ]
179
+ )
180
+
181
+ monkeypatch.setattr(
182
+ external_api_tool.requests,
183
+ "get",
184
+ fake_get,
185
+ )
186
+
187
+ with pytest.raises(
188
+ ValueError,
189
+ match="No country found",
190
+ ):
191
+ external_api_tool.lookup_country(
192
+ "DefinitelyNotARealCountryXYZ"
193
+ )