Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- .env.example +2 -0
- LICENSE +21 -0
- README.md +309 -15
- app.py +192 -0
- data/.gitkeep +0 -0
- logs/.gitkeep +0 -0
- requirements.txt +6 -0
- src/__init__.py +0 -0
- src/agent.py +209 -0
- src/audit.py +87 -0
- src/executor.py +140 -0
- src/schemas.py +45 -0
- src/tool_registry.py +122 -0
- src/tools/__init__.py +0 -0
- src/tools/calculator.py +88 -0
- src/tools/database.py +107 -0
- src/tools/external_api.py +117 -0
- tests/__init__.py +0 -0
- tests/test_executor.py +117 -0
- tests/test_tools.py +193 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|