Skip to content

Commit 86b7de5

Browse files
committed
feat: add comprehensive test suite for SDK modules
New test files for agent, discovery, evaluate, memory_config, memory_tools, plan, toolkit, and tracing modules. Updated discovery, tools, and workflow with improved functionality.
1 parent d499818 commit 86b7de5

12 files changed

Lines changed: 2162 additions & 29 deletions

src/hawk/discovery.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@ class WellKnownResolver:
118118
card = await resolver.resolve("assistant")
119119
"""
120120

121-
def __init__(self, base_urls: list[str] | None = None) -> None:
121+
def __init__(self, base_urls: list[str] | None = None, timeout: float = 5.0) -> None:
122122
self._base_urls = base_urls or []
123123
self._cache: dict[str, AgentCard] = {}
124+
self._timeout = timeout
124125

125126
async def resolve(self, agent_name: str) -> AgentCard | None:
126127
if agent_name in self._cache:
@@ -151,7 +152,7 @@ async def _fetch_card(self, base_url: str) -> AgentCard | None:
151152
import httpx
152153

153154
url = f"{base_url.rstrip('/')}/.well-known/agent.json"
154-
async with httpx.AsyncClient(timeout=5.0) as client:
155+
async with httpx.AsyncClient(timeout=self._timeout) as client:
155156
resp = await client.get(url)
156157
if resp.status_code == 200:
157158
data = resp.json()

src/hawk/tools.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,44 @@ def chat_with_tools(
116116
Returns:
117117
The final ChatResponse after tool execution completes.
118118
"""
119-
{t.name: t for t in tools}
120-
[t.to_dict() for t in tools]
119+
tool_map = {t.name: t for t in tools}
120+
tool_schemas = [t.to_dict() for t in tools]
121121

122122
# Initial chat request — we pass tool definitions alongside the prompt.
123123
# The Hawk API accepts tools in the request body.
124124
response = client.chat(
125125
prompt,
126126
session_id=session_id,
127127
model=model,
128+
tools=tool_schemas,
128129
**kwargs,
129130
)
130131

132+
# Handle tool-call responses in a loop up to max_rounds.
133+
for _round in range(max_rounds):
134+
if not hasattr(response, "tool_calls") or not response.tool_calls:
135+
break
136+
137+
# Execute each tool call and collect results.
138+
tool_results = []
139+
for tc in response.tool_calls:
140+
tool_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
141+
arguments = tc.get("arguments", {}) if isinstance(tc, dict) else getattr(tc, "arguments", {})
142+
if tool_name and tool_name in tool_map:
143+
result = _execute_tool(tool_map[tool_name], arguments)
144+
else:
145+
result = json.dumps({"error": f"Unknown tool: {tool_name}"})
146+
tool_results.append({"tool_use_id": tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", ""), "content": result})
147+
148+
# Send tool results back to continue the conversation.
149+
response = client.chat(
150+
prompt,
151+
session_id=getattr(response, "session_id", session_id),
152+
model=model,
153+
tool_results=tool_results,
154+
**kwargs,
155+
)
156+
131157
# For now, the Hawk daemon handles tool routing internally.
132158
# This loop structure supports future explicit tool-call responses.
133159
return cast("ChatResponse", response)
@@ -156,14 +182,38 @@ async def chat_with_tools_async(
156182
Returns:
157183
The final ChatResponse after tool execution completes.
158184
"""
159-
{t.name: t for t in tools}
160-
[t.to_dict() for t in tools]
185+
tool_map = {t.name: t for t in tools}
186+
tool_schemas = [t.to_dict() for t in tools]
161187

162188
response = await client.chat(
163189
prompt,
164190
session_id=session_id,
165191
model=model,
192+
tools=tool_schemas,
166193
**kwargs,
167194
)
168195

196+
# Handle tool-call responses in a loop up to max_rounds.
197+
for _round in range(max_rounds):
198+
if not hasattr(response, "tool_calls") or not response.tool_calls:
199+
break
200+
201+
tool_results = []
202+
for tc in response.tool_calls:
203+
tool_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
204+
arguments = tc.get("arguments", {}) if isinstance(tc, dict) else getattr(tc, "arguments", {})
205+
if tool_name and tool_name in tool_map:
206+
result = await _execute_tool_async(tool_map[tool_name], arguments)
207+
else:
208+
result = json.dumps({"error": f"Unknown tool: {tool_name}"})
209+
tool_results.append({"tool_use_id": tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", ""), "content": result})
210+
211+
response = await client.chat(
212+
prompt,
213+
session_id=getattr(response, "session_id", session_id),
214+
model=model,
215+
tool_results=tool_results,
216+
**kwargs,
217+
)
218+
169219
return cast("ChatResponse", response)

src/hawk/workflow.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import concurrent.futures
67
from dataclasses import dataclass
78
from typing import Any, Callable, TypeVar
89

@@ -87,37 +88,36 @@ def run(self, initial_input: Any = None) -> Any:
8788
if not self._built:
8889
raise RuntimeError("Workflow must be built before running")
8990

90-
import signal
91-
import threading
92-
9391
current = initial_input
9492

9593
for s in self._steps:
96-
if s.timeout is not None and threading.current_thread() is threading.main_thread():
97-
step_name = s.name
98-
step_timeout = s.timeout
99-
100-
# Use signal-based timeout on main thread
101-
def _timeout_handler(
102-
signum: int,
103-
frame: Any,
104-
step_name: str = step_name,
105-
step_timeout: float = step_timeout,
106-
) -> None:
107-
raise TimeoutError(f"Step '{step_name}' timed out after {step_timeout}s")
108-
109-
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
110-
signal.setitimer(signal.ITIMER_REAL, step_timeout)
111-
try:
112-
current = self._run_step_sync(s, current)
113-
finally:
114-
signal.setitimer(signal.ITIMER_REAL, 0)
115-
signal.signal(signal.SIGALRM, old_handler)
94+
if s.timeout is not None:
95+
current = self._run_step_with_timeout(s, current)
11696
else:
11797
current = self._run_step_sync(s, current)
11898

11999
return current
120100

101+
def _run_step_with_timeout(self, s: Step, input_val: Any) -> Any:
102+
"""Run a step in a worker thread with a timeout.
103+
104+
Uses a ThreadPoolExecutor so the timeout works on any thread
105+
(not just the main thread) and on all platforms (no SIGALRM).
106+
107+
Note: the worker thread keeps running after a timeout; only the
108+
caller is unblocked. This is inherent to cooperative cancellation
109+
in Python and matches asyncio.wait_for semantics.
110+
"""
111+
assert s.timeout is not None
112+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
113+
future = pool.submit(self._run_step_sync, s, input_val)
114+
try:
115+
return future.result(timeout=s.timeout)
116+
except TimeoutError:
117+
raise TimeoutError(
118+
f"Step '{s.name}' timed out after {s.timeout}s"
119+
) from None
120+
121121
def _run_step_sync(self, s: Step, input_val: Any) -> Any:
122122
"""Run a single step with optional retry."""
123123
if s.retry_config is not None:

0 commit comments

Comments
 (0)