Skip to content

Add Parallel tool calling support for byllm#5590

Open
Hushan-10 wants to merge 20 commits intojaseci-labs:mainfrom
Hushan-10:parallel_tool_calling
Open

Add Parallel tool calling support for byllm#5590
Hushan-10 wants to merge 20 commits intojaseci-labs:mainfrom
Hushan-10:parallel_tool_calling

Conversation

@Hushan-10
Copy link
Copy Markdown
Collaborator

@Hushan-10 Hushan-10 commented Apr 17, 2026

No description provided.

Copilot AI review requested due to automatic review settings April 17, 2026 03:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an (intended) parallel tool-calling capability to jac-byllm by introducing a reusable parallel dispatch/scheduling module and extending tool metadata to indicate whether a tool is safe to run concurrently.

Changes:

  • Adds a new byllm.parallel module with sequential + ThreadPool-based dispatch and a default scheduler.
  • Extends Tool and MockToolCall types with a parallel_safe flag.
  • Re-exports the new parallel utilities from byllm.lib and adds a tool_scheduler hook on BaseLLM (interface/doc only).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.

File Description
jac-byllm/byllm/types.jac Adds parallel_safe metadata to tools and mock tool calls.
jac-byllm/byllm/parallel.jac Implements default scheduling and dispatch helpers for parallel tool execution.
jac-byllm/byllm/llm.jac Adds a tool_scheduler field and declares an _dispatch_tool_batch contract on BaseLLM.
jac-byllm/byllm/lib.jac Exposes the new parallel APIs via the package __all__.

Comment on lines +37 to +72
}
"""
def mark_parallel_safe(func: Any) -> Any {
setattr(func, '_parallel_safe', True);
return func;
}


"""Sequential fallback — identical to BaseLLM._dispatch_tool_batch default.

Executes tool calls one-by-one in input order. Used when the batch is
ineligible for parallel execution (single-tool, mixed parallel_safe,
or any dispatch-level error).

Returns list of (ToolCall, ToolCallResultMsg, duration_ms) in input order.
"""
def sequential_dispatch(tool_calls: list) -> list {
results: list = [];
for tc in tool_calls {
t0 = time.time();
msg = tc();
ms = int((time.time() - t0) * 1000);
results.append((tc, msg, ms));
}
return results;
}


"""Execute a single tool call in a worker thread.

On exception, wraps the error in a ToolCallResultMsg so the caller
always receives a valid result tuple (never raises).
"""
def _run_worker(tc: Any) -> tuple {
t0 = time.time();
try {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module uses the Any type in function signatures (mark_parallel_safe, _run_worker) but doesn’t import it. Add import from typing { Any } (or equivalent) so this file typechecks/compiles consistently with the rest of byllm.

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/parallel.jac Outdated
Comment on lines +27 to +32
"""Mark one or more tool functions as parallel-safe.

Sets the _parallel_safe attribute on each function so that Tool.postinit
picks it up automatically when wrapping the function. Call this before
the first by-llm invocation that uses the tool.

Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mark_parallel_safe() documents that setting func._parallel_safe will be “picked up automatically when wrapping the function” (via Tool.postinit), but Tool.postinit currently never reads _parallel_safe to populate the new Tool.parallel_safe field. As a result, default_parallel_scheduler’s _all_parallel_safe() will likely always see False and never dispatch in parallel unless callers manually set Tool.parallel_safe. Update Tool.postinit (or Tool construction) to set self.parallel_safe = bool(getattr(self.func, "_parallel_safe", False)) (and ensure the flag is preserved when tools are built from call_params).

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/types.jac
info: Info = None,
parallel_safe: bool = False;

def postinit -> None;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool.parallel_safe is introduced here, but the current Tool.postinit implementation does not set it from func._parallel_safe (as implied by byllm.parallel.mark_parallel_safe). Without that wiring, tools created internally (e.g., from call_params.get("tools", [])) will keep the default False and parallel scheduling will never trigger. Please ensure Tool.postinit (or the tool factory path) assigns parallel_safe based on the wrapped function attribute.

Suggested change
def postinit -> None;
def postinit -> None {
self.parallel_safe = self.parallel_safe or bool(
getattr(self.func, "_parallel_safe", False)
);
}

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/types.jac
args: dict,
parallel_safe: bool = False;

def to_tool_call -> ToolCall;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MockToolCall.parallel_safe is added, but MockToolCall.to_tool_call currently constructs Tool(self.tool) without propagating the flag to the resulting Tool. This makes it hard to write deterministic tests around parallel eligibility using MockToolCall. Consider forwarding parallel_safe into the created Tool (or setting tool.func._parallel_safe before wrapping) so mock calls reflect the intended scheduling behavior.

Suggested change
def to_tool_call -> ToolCall;
def to_tool_call -> ToolCall {
return ToolCall(
tool=Tool(self.tool, parallel_safe=self.parallel_safe),
args=self.args,
);
}

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/parallel.jac Outdated
Comment on lines +111 to +137
try {
futures: dict = {};
raw: dict = {};
with ThreadPoolExecutor(max_workers=workers) as pool {
for (i, tc) in enumerate(tool_calls) {
f = pool.submit(_run_worker, tc);
futures[f] = i;
}
for f in as_completed(futures) {
idx = futures[f];
try {
raw[idx] = f.result();
} except Exception as e {
tc_err = tool_calls[idx];
err_msg = ToolCallResultMsg(
content=f"Parallel dispatch error: {str(e)}",
tool_call_id=tc_err.call_id,
name=tc_err.tool.get_name()
);
raw[idx] = (tc_err, err_msg, 0);
}
}
}
} except Exception {
_plog.warning("batch fallback=sequential reason=pool_error size=%d", n);
return sequential_dispatch(tool_calls);
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parallel_dispatch() falls back to sequential_dispatch() on any exception in the pool block. If an exception occurs after some futures have already started or completed, this can cause tools to be executed twice (once in threads, once in the sequential fallback), which is dangerous for non-idempotent tools. Consider only falling back when the pool cannot be created/submitted, or instead returning per-tool error results for unfinished futures without re-running successful/started calls.

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/parallel.jac Outdated
Comment on lines +159 to +209
"""Default parallel scheduler for byllm applications.

Install on any BaseLLM instance to enable automatic parallel dispatch
for tool batches where every tool is marked parallel_safe=True.

Decision tree (first matching rule wins):
1. Batch size <= 1 → sequential (no parallelism benefit)
2. Any tool has parallel_safe=False → sequential (safety)
3. All tools parallel_safe=True → parallel via ThreadPoolExecutor

Returns list of (ToolCall, ToolCallResultMsg, duration_ms) in INPUT ORDER.
"""
def default_parallel_scheduler(tool_calls: list) -> list {
n = len(tool_calls);
names = _tool_names(tool_calls);

# Guard: no benefit from a pool with one or zero workers.
if n <= 1 {
_plog.debug("batch dispatch=sequential reason=single_or_empty size=%d", n);
return sequential_dispatch(tool_calls);
}

# Guard: any non-parallel-safe tool forces the whole batch sequential.
if not _all_parallel_safe(tool_calls) {
_plog.info(
"batch dispatch=sequential reason=mixed_safety size=%d tools=%s", n, names
);
return sequential_dispatch(tool_calls);
}

# --- Parallel path ---
workers = min(n, _MAX_WORKERS);
_plog.info(
"batch dispatch=parallel size=%d workers=%d tools=%s", n, workers, names
);
t_batch = time.time();

results = parallel_dispatch(tool_calls, max_workers=workers);

wall_ms = int((time.time() - t_batch) * 1000);
sum_ms = 0;
for (_, _, ms) in results {
sum_ms += ms;
}
speedup = round(sum_ms / wall_ms, 2) if wall_ms > 0 else 0.0;
_plog.info(
"batch done=parallel size=%d workers=%d wall_ms=%d sum_tool_ms=%d speedup=%sx",
n, workers, wall_ms, sum_ms, speedup
);

return results;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New scheduling/dispatch logic is introduced here (default_parallel_scheduler, parallel_dispatch, error wrapping), but there doesn’t appear to be test coverage validating (1) input-order preservation, (2) sequential vs parallel decision rules, and (3) exception-to-ToolCallResultMsg wrapping. Adding focused tests (likely via MockToolCall / MockLLM) would help prevent regressions in tool execution semantics.

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/llm.jac Outdated
Comment on lines +71 to +109
@@ -86,6 +93,20 @@ obj BaseLLM {
) -> Generator[StreamEvent, None, None];

def _invoke_streaming(mt_run: MTRuntime) -> Generator[StreamEvent, None, None];

"""Execute a batch of non-finish tool calls and return results in input order.

default: sequential — byte-for-byte equivalent to the old inline loop.
jac-code installs a parallel scheduler by setting
`llm.tool_scheduler = my_classifier_fn` after constructing the model.

Contract:
- Input: list of ToolCall objects, all non-finish calls
- Output: list of (ToolCall, ToolCallResultMsg, duration_ms) in SAME ORDER as input
- Must NOT mutate mt_run; caller handles mt_run.add_message()
- A failing tool must return (tc, error_result_msg, ms) — not raise
"""
def _dispatch_tool_batch(tool_calls: list) -> list;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds tool_scheduler + an _dispatch_tool_batch() contract on BaseLLM, but the current BaseLLM.invoke implementation still executes tool calls inline and never references tool_scheduler / _dispatch_tool_batch. As-is, the new parallel scheduler won’t be exercised in normal runs. Either wire invoke (and the streaming ReAct loop) to call _dispatch_tool_batch (with a default implementation that uses tool_scheduler when set), or adjust the PR scope/docs to match what’s actually shipped.

Copilot uses AI. Check for mistakes.
Comment thread jac-byllm/byllm/llm.jac Outdated
Comment on lines +71 to +77
# optional parallel scheduler injected by the caller.
# When set, _dispatch_tool_batch delegates the entire batch to this
# callable instead of running sequentially.
# Signature: (tool_calls: list) -> list[(ToolCall, ToolCallResultMsg, ms)]
# jac-code installs parallel_tool_scheduler here when the feature flag
# JAC_CODER_PARALLEL_TOOLS is set. Default None = sequential.
tool_scheduler: Any = None;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any is used for tool_scheduler, but this file only imports Generator from typing. If Any isn’t implicitly in scope, this will fail to typecheck/compile. Import Any from typing (or change the annotation to an existing type in scope).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@udithishanka udithishanka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 correctness bugs and a few important issues.

Comment thread jac-byllm/byllm/parallel.jac Outdated
Comment on lines +226 to +253
try {
futures: dict = {};
for tc in tool_calls {
f = _WORKER_POOL.submit(_run_one, tc);
futures[f] = tc;
}
for f in as_completed(futures) {
try {
yield f.result();
} except Exception as e {
tc_err = futures[f];
err_msg = ToolCallResultMsg(
content=f"Parallel dispatch error: {e}",
tool_call_id=tc_err.call_id,
name=tc_err.tool.get_name()
);
yield (tc_err, err_msg, 0);
}
}
} except Exception as e {
_plog.warning(
"batch fallback=sequential reason=pool_error size=%d error=%s", n, e
);
for tc in tool_calls {
yield _run_one(tc);
}
return;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the same double-execution bug copilot flagged on the previous HEAD. by the time the outer except fires, some futures have already completed and their results have been yielded — the tools ran. the fallback loop then re-runs the entire batch via _run_one(tc), so non-idempotent tools execute twice. the per-future try/except inside as_completed already handles per-tool errors; drop the outer fallback entirely.

Suggested change
try {
futures: dict = {};
for tc in tool_calls {
f = _WORKER_POOL.submit(_run_one, tc);
futures[f] = tc;
}
for f in as_completed(futures) {
try {
yield f.result();
} except Exception as e {
tc_err = futures[f];
err_msg = ToolCallResultMsg(
content=f"Parallel dispatch error: {e}",
tool_call_id=tc_err.call_id,
name=tc_err.tool.get_name()
);
yield (tc_err, err_msg, 0);
}
}
} except Exception as e {
_plog.warning(
"batch fallback=sequential reason=pool_error size=%d error=%s", n, e
);
for tc in tool_calls {
yield _run_one(tc);
}
return;
}
futures: dict = {};
for tc in tool_calls {
f = _WORKER_POOL.submit(_run_one, tc);
futures[f] = tc;
}
for f in as_completed(futures) {
try {
yield f.result();
} except Exception as e {
tc_err = futures[f];
err_msg = ToolCallResultMsg(
content=f"Parallel dispatch error: {e}",
tool_call_id=tc_err.call_id,
name=tc_err.tool.get_name()
);
yield (tc_err, err_msg, 0);
}
}
wall_ms = int((time.time() - t_batch) * 1000);
_plog.info(
"batch done=parallel size=%d workers=%d wall_ms=%d", n, workers, wall_ms
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. removed the outer try/except fallback. The per-future try/except inside as_completed already handles errors per-tool; the outer block caused double-execution of non-idempotent tools.

Comment on lines +99 to +112
def _call_sync(tc: Any, timeout: Any) -> Any {
if timeout is not None {
f = _WORKER_POOL.submit(tc);
try {
return f.result(timeout=timeout);
} except Exception as e {
f.cancel();
raise TimeoutError(
f"Tool '{tc.tool.get_name()}' timed out after {timeout}s"
) from e;
}
}
return tc();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pool-within-pool deadlock. dispatch_batch submits _run_one wrappers to _WORKER_POOL (L229). each wrapper calls _call_sync, which submits tc back to the same pool (L101) and blocks on f.result(timeout=...). with N tools all using timeout_sec, N outer workers occupy every slot waiting on N nested submissions that can't schedule. hard deadlock.

swap to threading.Thread(...).join(timeout=...) so the timeout wait doesn't require a pool slot:

Suggested change
def _call_sync(tc: Any, timeout: Any) -> Any {
if timeout is not None {
f = _WORKER_POOL.submit(tc);
try {
return f.result(timeout=timeout);
} except Exception as e {
f.cancel();
raise TimeoutError(
f"Tool '{tc.tool.get_name()}' timed out after {timeout}s"
) from e;
}
}
return tc();
}
def _call_sync(tc: Any, timeout: Any) -> Any {
if timeout is None {
return tc();
}
import threading;
result_box: list = [None];
error_box: list = [None];
def _worker -> None {
try {
result_box[0] = tc();
} except Exception as e {
error_box[0] = e;
}
}
t = threading.Thread(target=_worker, daemon=True);
t.start();
t.join(timeout=timeout);
if t.is_alive() {
raise TimeoutError(
f"Tool '{tc.tool.get_name()}' timed out after {timeout}s"
);
}
if error_box[0] is not None {
raise error_box[0];
}
return result_box[0];
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. replaced _WORKER_POOL.submit with threading.Thread + join(timeout=...) to avoid pool-within-pool deadlock when all workers have timeout_sec set.

Comment on lines +102 to +109
try {
return f.result(timeout=timeout);
} except Exception as e {
f.cancel();
raise TimeoutError(
f"Tool '{tc.tool.get_name()}' timed out after {timeout}s"
) from e;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this catches every exception from f.result(timeout=timeout) and re-raises as TimeoutError. if the tool itself raises ValueError, the user sees Tool 'X' timed out after 5s — completely wrong diagnosis. catch only concurrent.futures.TimeoutError.

(obsoleted if you adopt the threading suggestion above — noting it separately in case you keep the pool-based version.)

Copy link
Copy Markdown
Collaborator Author

@Hushan-10 Hushan-10 Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already resolved by the threading.Thread fix from comment 2 . _call_sync no longer catches Exception broadly. TimeoutError is only raised when t.is_alive() after join; tool-raised exceptions propagate with their original type.

Comment on lines +116 to +128
def _call_async(tc: Any, timeout: Any) -> Any {
async def _run -> Any {
coro = tc.tool.func(**tc.args);
if timeout is not None {
return await asyncio.wait_for(coro, timeout=timeout);
}
return await coro;
}
result_val = asyncio.run(_run());
return ToolCallResultMsg(
content=str(result_val), tool_call_id=tc.call_id, name=tc.tool.get_name()
);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two things here. (1) asyncio.run() raises RuntimeError if the calling thread already has a running event loop — possible in some jac runtime contexts. (2) asyncio.TimeoutError from wait_for gets caught by the generic except Exception in _run_one (L145) and reported as Tool error: ... instead of the timeout-specific message the sync path produces. inconsistent UX.

min fix: in _run_one, handle TimeoutError / asyncio.TimeoutError before the generic catch and format with the same timed out after Xs shape as _call_sync.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. added except (TimeoutError, asyncio.TimeoutError) as a dedicated handler in _run_one before the generic catch. Both sync and async timeout paths now produce the same "Tool 'X' timed out after Ys" message.

if non_finish_calls {
# Collect results from dispatch_batch generator, then
# add messages in input order for deterministic history.
import from byllm.parallel { dispatch_batch, _index_by_identity }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this re-imports on every ReAct iteration. move to the top of the file with the other imports. same story at L717 in the streaming path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. moved all three inline imports to a single top-level import from byllm.parallel in llm.jac. No more per-iteration re-imports.

Comment on lines +234 to +242
first_msg = messages[0];
if isinstance(first_msg, dict) and first_msg.get("role") == "system" {
import from byllm.parallel { _PARALLEL_TOOL_HINT }
content = first_msg.get("content", "");
if isinstance(content, str) and _PARALLEL_TOOL_HINT not in content {
first_msg["content"] = content + _PARALLEL_TOOL_HINT;
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first_msg["content"] = content + _PARALLEL_TOOL_HINT mutates the dict in place. if mt_run.get_msg_list() returns a reference (it does — it's the persistent message list), you're mutating shared state from inside a param-building method. the not in content guard prevents double-append in the same call, but this still means the hint persists into mt_run.messages and leaks across iterations. copy the message before appending:

Suggested change
first_msg = messages[0];
if isinstance(first_msg, dict) and first_msg.get("role") == "system" {
import from byllm.parallel { _PARALLEL_TOOL_HINT }
content = first_msg.get("content", "");
if isinstance(content, str) and _PARALLEL_TOOL_HINT not in content {
first_msg["content"] = content + _PARALLEL_TOOL_HINT;
}
}
}
if isinstance(first_msg, dict) and first_msg.get("role") == "system" {
import from byllm.parallel { _PARALLEL_TOOL_HINT }
content = first_msg.get("content", "");
if isinstance(content, str) and _PARALLEL_TOOL_HINT not in content {
messages = list(messages);
messages[0] = {**first_msg, "content": content + _PARALLEL_TOOL_HINT};
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. make_model_params now copies the list and creates a new dict before appending the hint: messages = list(messages); messages[0] = {**first_msg, "content": ...}. Original mt_run.messages is never mutated.

Comment thread jac-byllm/tests/test_parallel.jac Outdated
Comment on lines +319 to +441
test "parallel tool hint constant in source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "parallel.jac";
content = src.read_text();
assert "_PARALLEL_TOOL_HINT" in content;
assert "parallel" in content.lower();
assert "independent" in content.lower();
}


# ---------------------------------------------------------------------------
# 13. Per-tool timeout attribute
# ---------------------------------------------------------------------------

test "timeout attribute stored on tool" {
tool = _FakeTool(name_str="slow_tool", timeout_sec=0.05);
assert tool.timeout_sec == 0.05;
}

test "timeout none means no timeout" {
tool = _FakeTool(name_str="normal_tool");
assert tool.timeout_sec is None;
}


# ---------------------------------------------------------------------------
# 14. mark_serialize helper
# ---------------------------------------------------------------------------

test "mark serialize sets underscore serialize attribute" {
def my_func() -> None { }
assert not hasattr(my_func, '_serialize');
mark_serialize(my_func);
assert getattr(my_func, '_serialize') == True;
}

test "mark serialize function exists in source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "parallel.jac";
content = src.read_text();
assert "def mark_serialize" in content;
assert "setattr(func, '_serialize', True)" in content;
}


# ---------------------------------------------------------------------------
# 15. System prompt nudge wired into basellm
# ---------------------------------------------------------------------------

test "system prompt nudge in basellm source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "llm.impl" / "basellm.impl.jac";
content = src.read_text();
assert "_PARALLEL_TOOL_HINT" in content;
assert 'first_msg["content"]' in content;
}


# ---------------------------------------------------------------------------
# 16. Shared module-level pool in source
# ---------------------------------------------------------------------------

test "shared worker pool defined in source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "parallel.jac";
content = src.read_text();
assert "_WORKER_POOL" in content;
assert "ThreadPoolExecutor" in content;
assert "BYLLM_TOOL_WORKERS" in content;
}


# ---------------------------------------------------------------------------
# 17. _serialize detection in Tool.postinit
# ---------------------------------------------------------------------------

test "serialize detection in tool postinit source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "types.impl" / "tool.impl.jac";
content = src.read_text();
assert "_serialize" in content;
assert "self.serialize" in content;
}


# ---------------------------------------------------------------------------
# 18. MockLLM batch fixture (scheduler_parity) exists
# ---------------------------------------------------------------------------

test "scheduler parity fixture exists and has batch tool calls" {
fixture = pathlib.Path(_TESTS_DIR) / "fixtures" / "scheduler_parity.jac";
assert fixture.exists();
content = fixture.read_text();
assert "MockToolCall" in content;
assert "SCHEDULER_PARITY_PASS" in content;
}


# ---------------------------------------------------------------------------
# 19. Async tool support in source
# ---------------------------------------------------------------------------

test "async tool support functions in source" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "parallel.jac";
content = src.read_text();
assert "_call_async" in content;
assert "_is_async" in content;
assert "asyncio.run" in content;
}


# ---------------------------------------------------------------------------
# 20. Old API removed from llm.jac
# ---------------------------------------------------------------------------

test "tool scheduler removed from llm" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "llm.jac";
content = src.read_text();
assert "tool_scheduler" not in content;
assert "_dispatch_tool_batch" not in content;
}

test "basellm uses dispatch batch not old api" {
src = pathlib.Path(_ROOT_DIR) / "byllm" / "llm.impl" / "basellm.impl.jac";
content = src.read_text();
assert "dispatch_batch" in content;
assert "_dispatch_tool_batch" not in content;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests 12, 14 (second), 15-20 are source-text assertions (pathlib.read_text() + "X" in content). these pass even if imports are broken or functions are stubbed — they validate string presence in source, not runtime behavior. e.g. test 17 checks "_serialize" in content of tool.impl.jac, but if Tool.postinit never actually reads it, the test still passes.

replace with actual imports + runtime calls. for example, test 17 should construct a tool with _serialize=True on the func and assert Tool(func).serialize == True. the behavioral tests (1-11, 13, 14-first) are fine — keep those.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. replaced all 10 source-text assertions with runtime behavioral tests. Tests now import real modules (Tool, MockToolCall, BaseLLM, _WORKER_POOL, _PARALLEL_TOOL_HINT) and exercise actual behavior (construct objects, call functions, verify attributes)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants