Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 145 additions & 1 deletion tests/edge_cases/test_sandbox_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,150 @@
- REPL command that modifies agent state during generation
- REPL command that calls tool during generation
- Generated code that calls REPL command (should fail)

"REPL command" means a CodeAct ``execute_python`` cell. With
``execution_backend="sandbox"``, cells run in a worker process and broker
``self.*`` to the live parent agent.
"""

# TODO: Add sandbox edge case tests
from __future__ import annotations

import json
from typing import Any

import pytest

from nooa import Agent, strategy
from nooa.config import CodeActConfig
from nooa.runtime.sandbox.config import SandboxConfig
from nooa.strategies.codeact import CodeActStrategy
from nooa.unifiedllm import FakeLLMClient, LLMResponse, ToolCall


def _resp(content: str = "", tool_calls: list | None = None) -> LLMResponse:
return LLMResponse(
raw_response=None,
content=content,
tool_calls=tool_calls or [],
finish_reason="tool_calls" if tool_calls else "stop",
assistant_message={"role": "assistant", "content": content},
)


def _exec(code: str, call_id: str = "c1") -> ToolCall:
return ToolCall(id=call_id, name="execute_python", arguments=json.dumps({"code": code}))


def _ret(result: Any = None, call_id: str = "cret") -> ToolCall:
return ToolCall(id=call_id, name="return_result", arguments=json.dumps({"result": result}))


# Fail-open for CI hosts without Landlock/seccomp; network stays on so parent-side
# FakeLLM traffic is unrelated to worker filesystem/network policy.
_SANDBOX = SandboxConfig(require=False, network=True, filesystem=False)
_CODEACT = CodeActStrategy(
config=CodeActConfig(
execution_backend="sandbox",
cell_timeout=15.0,
sandbox=_SANDBOX,
prefill=None,
)
)


class _StateAgent(Agent, llm=FakeLLMClient()):
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.counter = 0

@strategy(_CODEACT)
async def work(self) -> int:
"""Bump counter via a sandboxed cell assignment."""
...


class _ToolAgent(Agent, llm=FakeLLMClient()):
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.hits = 0

def bump(self) -> int:
"""Deterministic tool callable from a sandboxed cell."""
self.hits += 1
return self.hits

@strategy(_CODEACT)
async def work(self) -> int:
"""Call bump() from a sandboxed cell."""
...


class _ReplCallAgent(Agent, llm=FakeLLMClient()):
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.saw_execute_python_error = False

def note_error(self, msg: str) -> None:
if "execute_python" in msg and "not defined" in msg:
self.saw_execute_python_error = True

@strategy(_CODEACT)
async def work(self) -> int:
"""Attempt to invoke execute_python from inside a cell (should fail)."""
...


@pytest.mark.asyncio
async def test_repl_command_modifies_agent_state_during_generation():
"""Cell assignment ``self.counter = …`` lands on the live parent agent."""
llm = FakeLLMClient(
scripted_responses=[
_resp("", [_exec("self.counter = 99\nprint(self.counter)")]),
_resp("", [_ret(99)]),
]
)
agent = _StateAgent(llm=llm)
assert await agent.work() == 99
assert agent.counter == 99


@pytest.mark.asyncio
async def test_repl_command_calls_tool_during_generation():
"""Cell call ``self.bump()`` brokers to the live parent and records side effects."""
llm = FakeLLMClient(
scripted_responses=[
_resp("", [_exec("x = self.bump()\nprint(x)")]),
_resp("", [_ret(1)]),
]
)
agent = _ToolAgent(llm=llm)
assert await agent.work() == 1
assert agent.hits == 1


@pytest.mark.asyncio
async def test_generated_code_calling_repl_command_fails():
"""``execute_python`` is an LLM tool, not a REPL builtin — cells NameError it.

The strategy recovers from the cell error and continues to ``return_result``.
"""
llm = FakeLLMClient(
scripted_responses=[
_resp(
"",
[
_exec(
"try:\n"
" execute_python('print(1)')\n"
"except NameError as e:\n"
" self.note_error(str(e))\n"
" raise"
)
],
),
_resp("", [_ret(0)]),
]
)
agent = _ReplCallAgent(llm=llm)
assert await agent.work() == 0
assert agent.saw_execute_python_error is True
194 changes: 193 additions & 1 deletion tests/edge_cases/test_signal_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,198 @@
- Signal queued during generation session
- Signal that calls @strategy method needing generation
- Multiple signals queued during long generation

"Signals" here are implemented (non-ellipsis) agent methods invoked concurrently
while a generation method holds the actor's generation lock / session. They are
scheduled as asyncio tasks — there is no separate SignalQueue API.
"""

# TODO: Add signal edge case tests
from __future__ import annotations

import asyncio
from typing import Any

import pytest

from nooa import Agent, strategy
from nooa.strategies.pure_python import PurePythonStrategy
from nooa.unifiedllm import FakeLLMClient, LLMResponse


def _resp(content: str) -> LLMResponse:
"""Create a test LLM response with the given content."""
return LLMResponse(
raw_response=None,
content=content,
tool_calls=[],
finish_reason="stop",
assistant_message={"role": "assistant", "content": content},
)


class GatedFakeLLM(FakeLLMClient):
"""FakeLLM that blocks inside acall until ``gate`` is set.

Lets tests schedule concurrent "signal" work while a generation session
is still open (stock FakeLLMClient returns instantly).
"""

def __init__(
self,
scripted_responses: list[LLMResponse] | None = None,
*,
gate: asyncio.Event | None = None,
):
super().__init__(scripted_responses=scripted_responses)
self.entered = asyncio.Event()
self.gate = gate if gate is not None else asyncio.Event()
if gate is None:
self.gate.set()

async def acall(
self,
messages: list[dict[str, Any]],
tools: list | None = None,
output_model: type | None = None,
**kwargs: Any,
) -> LLMResponse:
self.entered.set()
await self.gate.wait()
return await super().acall(messages, tools=tools, output_model=output_model, **kwargs)


_TEST_LLM = FakeLLMClient()


class SignalAgent(Agent, llm=_TEST_LLM):
"""Agent for signal-during-generation edge cases."""

def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.logged_events: list[str] = []
self.order: list[str] = []

@strategy(PurePythonStrategy())
async def long_gen(self) -> str:
"""Long generation session held open by a gated LLM."""
...

@strategy(PurePythonStrategy())
async def nested_gen(self) -> str:
"""Generation method invoked from a concurrent signal."""
...

async def log_signal(self, msg: str) -> None:
"""Implemented method — runs without the generation lock."""
self.logged_events.append(f"signal-{msg}")
self.order.append(f"signal-{msg}")

async def signal_calls_gen(self) -> str:
"""Implemented method that awaits a generation method (needs the lock)."""
self.order.append("signal-enter")
result = await self.nested_gen()
self.order.append("signal-exit")
return result


@pytest.mark.asyncio
async def test_signal_queued_during_generation_session():
"""Implemented signal runs while a generation session is still open."""
gate = asyncio.Event()
fake = GatedFakeLLM(scripted_responses=[_resp("return 'done'")], gate=gate)
agent = SignalAgent(llm=fake)

gen_task = asyncio.create_task(agent.long_gen())
await fake.entered.wait()

sig_task = asyncio.create_task(agent.log_signal("mid"))
await asyncio.wait_for(sig_task, timeout=1.0)

# Signal completed while generation was still held open.
assert agent.logged_events == ["signal-mid"]
assert not gen_task.done()

gate.set()
assert await gen_task == "done"
assert fake.call_count == 1


@pytest.mark.asyncio
async def test_signal_that_calls_strategy_method_needing_generation():
"""Signal awaiting a @strategy method waits for the outer generation lock."""
gate = asyncio.Event()
fake = GatedFakeLLM(
scripted_responses=[
_resp("return 'outer'"),
_resp("return 'from-sig'"),
],
gate=gate,
)
agent = SignalAgent(llm=fake)

gen_task = asyncio.create_task(agent.long_gen())
await fake.entered.wait()

sig_task = asyncio.create_task(agent.signal_calls_gen())
# Give the signal time to enter and block on the generation lock.
await asyncio.sleep(0.05)
Comment thread
alessiodevoto marked this conversation as resolved.
Outdated
assert agent.order == ["signal-enter"]
assert not sig_task.done()

gate.set()
assert await gen_task == "outer"
assert await sig_task == "from-sig"
assert fake.call_count == 2
assert agent.order == ["signal-enter", "signal-exit"]


@pytest.mark.asyncio
async def test_multiple_signals_queued_during_long_generation():
"""Several implemented signals all complete during one open generation."""
gate = asyncio.Event()
fake = GatedFakeLLM(scripted_responses=[_resp("return 'done'")], gate=gate)
agent = SignalAgent(llm=fake)

gen_task = asyncio.create_task(agent.long_gen())
await fake.entered.wait()

signal_tasks = [asyncio.create_task(agent.log_signal(str(i))) for i in range(5)]
await asyncio.wait_for(asyncio.gather(*signal_tasks), timeout=1.0)

assert agent.logged_events == [f"signal-{i}" for i in range(5)]
assert not gen_task.done()

gate.set()
assert await gen_task == "done"
assert fake.call_count == 1


@pytest.mark.asyncio
async def test_mixed_signals_during_generation_lock_and_no_lock():
"""Non-gen signals finish mid-session; gen-needing signals wait for unlock."""
gate = asyncio.Event()
fake = GatedFakeLLM(
scripted_responses=[
_resp("return 'outer'"),
_resp("return 'from-sig'"),
],
gate=gate,
)
agent = SignalAgent(llm=fake)

gen_task = asyncio.create_task(agent.long_gen())
await fake.entered.wait()

free_sig = asyncio.create_task(agent.log_signal("free"))
blocked_sig = asyncio.create_task(agent.signal_calls_gen())
await asyncio.wait_for(free_sig, timeout=1.0)
await asyncio.sleep(0.05)

assert agent.logged_events == ["signal-free"]
assert agent.order == ["signal-free", "signal-enter"]
assert not blocked_sig.done()

gate.set()
assert await gen_task == "outer"
assert await blocked_sig == "from-sig"
assert agent.order == ["signal-free", "signal-enter", "signal-exit"]
Loading