mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9c5f03ae | ||
|
|
1cd3431639 | ||
|
|
c62d0d5fa7 | ||
|
|
9474498e3e | ||
|
|
abf930a381 | ||
|
|
c2b1453b2e | ||
|
|
3fccd9ab9a | ||
|
|
12610138af | ||
|
|
5b9eba4318 |
@@ -50,6 +50,7 @@ from nanobot.utils.runtime import (
|
|||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
build_goal_continue_message,
|
build_goal_continue_message,
|
||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
|
build_runtime_budget_notice_message,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
repeated_workspace_violation_error,
|
repeated_workspace_violation_error,
|
||||||
@@ -67,6 +68,7 @@ _MAX_EMPTY_RETRIES = 2
|
|||||||
_MAX_LENGTH_RECOVERIES = 3
|
_MAX_LENGTH_RECOVERIES = 3
|
||||||
_MAX_INJECTIONS_PER_TURN = 3
|
_MAX_INJECTIONS_PER_TURN = 3
|
||||||
_MAX_INJECTION_CYCLES = 5
|
_MAX_INJECTION_CYCLES = 5
|
||||||
|
_BUDGET_NOTICE_MIN_ITERATIONS = 20
|
||||||
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||||
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||||
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||||
@@ -357,6 +359,7 @@ class AgentRunner:
|
|||||||
length_recovery_count = 0
|
length_recovery_count = 0
|
||||||
had_injections = False
|
had_injections = False
|
||||||
injection_cycles = 0
|
injection_cycles = 0
|
||||||
|
budget_notice_level_sent = 0
|
||||||
compacted_tool_call_ids: set[str] = set()
|
compacted_tool_call_ids: set[str] = set()
|
||||||
governance_config = ContextGovernanceConfig(
|
governance_config = ContextGovernanceConfig(
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
@@ -511,6 +514,12 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
if _drained:
|
if _drained:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
|
budget_notice_level_sent = self._append_runtime_budget_notice_if_needed(
|
||||||
|
spec,
|
||||||
|
messages,
|
||||||
|
completed_iterations=iteration + 1,
|
||||||
|
sent_level=budget_notice_level_sent,
|
||||||
|
)
|
||||||
await hook.after_iteration(context)
|
await hook.after_iteration(context)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -940,6 +949,53 @@ class AgentRunner:
|
|||||||
retry_messages.append(build_budget_exhausted_finalization_message())
|
retry_messages.append(build_budget_exhausted_finalization_message())
|
||||||
return retry_messages
|
return retry_messages
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_runtime_budget_notice_if_needed(
|
||||||
|
cls,
|
||||||
|
spec: AgentRunSpec,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
completed_iterations: int,
|
||||||
|
sent_level: int,
|
||||||
|
) -> int:
|
||||||
|
level = cls._runtime_budget_notice_level(
|
||||||
|
max_iterations=spec.max_iterations,
|
||||||
|
completed_iterations=completed_iterations,
|
||||||
|
)
|
||||||
|
if level <= sent_level:
|
||||||
|
return sent_level
|
||||||
|
|
||||||
|
remaining_iterations = max(0, spec.max_iterations - completed_iterations)
|
||||||
|
messages.append(build_runtime_budget_notice_message(
|
||||||
|
level=level,
|
||||||
|
max_iterations=spec.max_iterations,
|
||||||
|
used_iterations=completed_iterations,
|
||||||
|
remaining_iterations=remaining_iterations,
|
||||||
|
))
|
||||||
|
return level
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _runtime_budget_notice_level(
|
||||||
|
*,
|
||||||
|
max_iterations: int,
|
||||||
|
completed_iterations: int,
|
||||||
|
) -> int:
|
||||||
|
"""Return the convergence-warning level for a long tool loop."""
|
||||||
|
if max_iterations < _BUDGET_NOTICE_MIN_ITERATIONS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
remaining_iterations = max_iterations - completed_iterations
|
||||||
|
if remaining_iterations <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
convergence_threshold = max(5, (max_iterations + 9) // 10)
|
||||||
|
final_threshold = max(3, (max_iterations + 32) // 33)
|
||||||
|
if remaining_iterations <= final_threshold:
|
||||||
|
return 2
|
||||||
|
if remaining_iterations <= convergence_threshold:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
|
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
|
||||||
if spec.max_iterations_message:
|
if spec.max_iterations_message:
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ from nanobot.agent.tools.schema import (
|
|||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
VerificationAnalysis,
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
|
from nanobot.utils.helpers import build_structured_output_summary
|
||||||
|
|
||||||
DEFAULT_YIELD_MS = 1000
|
DEFAULT_YIELD_MS = 1000
|
||||||
MAX_YIELD_MS = 30_000
|
MAX_YIELD_MS = 30_000
|
||||||
@@ -37,6 +44,7 @@ class _SessionPoll:
|
|||||||
terminated: bool = False
|
terminated: bool = False
|
||||||
stdin_closed: bool = False
|
stdin_closed: bool = False
|
||||||
truncated_chars: int = 0
|
truncated_chars: int = 0
|
||||||
|
analysis: VerificationAnalysis | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -147,7 +155,19 @@ class _ExecSession:
|
|||||||
output = "".join(self._chunks)
|
output = "".join(self._chunks)
|
||||||
self._chunks.clear()
|
self._chunks.clear()
|
||||||
|
|
||||||
output, truncated = _truncate_output(output, max_output_chars)
|
analysis = analyze_verification_result(
|
||||||
|
command=self.command,
|
||||||
|
output=output,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
timed_out=self._timed_out,
|
||||||
|
)
|
||||||
|
output, truncated = _truncate_output(
|
||||||
|
output,
|
||||||
|
max_output_chars,
|
||||||
|
analysis=analysis,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
elapsed_s=max(0.0, time.monotonic() - self.started_at),
|
||||||
|
)
|
||||||
return _SessionPoll(
|
return _SessionPoll(
|
||||||
output=output,
|
output=output,
|
||||||
done=self.process.returncode is not None,
|
done=self.process.returncode is not None,
|
||||||
@@ -157,6 +177,7 @@ class _ExecSession:
|
|||||||
terminated=terminated,
|
terminated=terminated,
|
||||||
stdin_closed=stdin_closed,
|
stdin_closed=stdin_closed,
|
||||||
truncated_chars=truncated,
|
truncated_chars=truncated,
|
||||||
|
analysis=analysis,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
@@ -320,15 +341,33 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
|
|||||||
return min(max(value, minimum), maximum)
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
def _truncate_output(
|
||||||
|
output: str,
|
||||||
|
max_output_chars: int,
|
||||||
|
*,
|
||||||
|
analysis: VerificationAnalysis | None = None,
|
||||||
|
exit_code: int | None = None,
|
||||||
|
elapsed_s: float | None = None,
|
||||||
|
) -> tuple[str, int]:
|
||||||
if len(output) <= max_output_chars:
|
if len(output) <= max_output_chars:
|
||||||
return output, 0
|
return output, 0
|
||||||
half = max_output_chars // 2
|
|
||||||
omitted = len(output) - max_output_chars
|
omitted = len(output) - max_output_chars
|
||||||
return (
|
return (
|
||||||
output[:half]
|
build_structured_output_summary(
|
||||||
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
"[tool output truncated]",
|
||||||
+ output[-half:],
|
output,
|
||||||
|
max_chars=max_output_chars,
|
||||||
|
metadata=[
|
||||||
|
("original_size_chars", len(output)),
|
||||||
|
("exit_code", exit_code if exit_code is not None else "running"),
|
||||||
|
("elapsed_s", f"{elapsed_s:.1f}" if elapsed_s is not None else "unknown"),
|
||||||
|
],
|
||||||
|
analysis=analysis,
|
||||||
|
guidance=(
|
||||||
|
"Use the structured summary first. Poll again for new output "
|
||||||
|
"or rerun a narrower command instead of reading broad logs."
|
||||||
|
),
|
||||||
|
),
|
||||||
omitted,
|
omitted,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -351,6 +390,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
return "\n".join(parts) if parts else "(no output yet)"
|
return "\n".join(parts) if parts else "(no output yet)"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_poll_with_verification(session_id: str, poll: _SessionPoll) -> str:
|
||||||
|
result = format_session_poll(session_id, poll)
|
||||||
|
if not poll.done:
|
||||||
|
return result
|
||||||
|
analysis = poll.analysis or analyze_verification_result(
|
||||||
|
command="",
|
||||||
|
output=result,
|
||||||
|
exit_code=poll.exit_code,
|
||||||
|
timed_out=poll.timed_out,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
||||||
@@ -492,7 +545,7 @@ class WriteStdinTool(Tool):
|
|||||||
max_output_chars=output_limit,
|
max_output_chars=output_limit,
|
||||||
owner_session_key=current_request_session_key(),
|
owner_session_key=current_request_session_key(),
|
||||||
)
|
)
|
||||||
return format_session_poll(session_id, poll)
|
return _format_poll_with_verification(session_id, poll)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return f"Error: exec session not found: {session_id}"
|
return f"Error: exec session not found: {session_id}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -532,10 +585,10 @@ class WriteStdinTool(Tool):
|
|||||||
joined = "".join(aggregate)
|
joined = "".join(aggregate)
|
||||||
if wait_for in joined:
|
if wait_for in joined:
|
||||||
poll.output = joined
|
poll.output = joined
|
||||||
return format_session_poll(session_id, poll)
|
return _format_poll_with_verification(session_id, poll)
|
||||||
if poll.done or remaining_ms <= 0:
|
if poll.done or remaining_ms <= 0:
|
||||||
poll.output = "".join(aggregate)
|
poll.output = "".join(aggregate)
|
||||||
result = format_session_poll(session_id, poll)
|
result = _format_poll_with_verification(session_id, poll)
|
||||||
if wait_for not in poll.output:
|
if wait_for not in poll.output:
|
||||||
result += f"\nWait target not observed: {wait_for!r}"
|
result += f"\nWait target not observed: {wait_for!r}"
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
clear_verification_observation,
|
||||||
|
format_completion_gate_message,
|
||||||
|
latest_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
GOAL_STATE_KEY,
|
GOAL_STATE_KEY,
|
||||||
@@ -187,6 +192,29 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
|||||||
max_length=8000,
|
max_length=8000,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
|
verification_summary=StringSchema(
|
||||||
|
"For coding or file-producing tasks, summarize how the work was verified. "
|
||||||
|
"Mention the most relevant test/check command and whether it passed. "
|
||||||
|
"If no verification was possible, say why.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
commands_run=StringSchema(
|
||||||
|
"Optional concise list of verification/build commands run before completion.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
artifacts_created=StringSchema(
|
||||||
|
"Optional concise list of files, outputs, or artifacts created.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
remaining_failures=StringSchema(
|
||||||
|
"Known unresolved failures, if intentionally stopping before success. "
|
||||||
|
"Leave empty when verification passes.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
required=[],
|
required=[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -222,30 +250,67 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|||||||
return (
|
return (
|
||||||
"End bookkeeping for the active sustained goal. "
|
"End bookkeeping for the active sustained goal. "
|
||||||
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
||||||
|
"For coding/file-producing tasks, run the smallest reliable verification first and include "
|
||||||
|
"verification_summary / commands_run / artifacts_created. "
|
||||||
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
||||||
"what actually happened (not necessarily success). "
|
"what actually happened (not necessarily success). "
|
||||||
|
"If recent verification failed and no later verification passed, this tool will ask you to "
|
||||||
|
"continue fixing unless remaining_failures describes an intentional incomplete stop. "
|
||||||
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
async def execute(
|
||||||
|
self,
|
||||||
|
recap: str | None = None,
|
||||||
|
verification_summary: str | None = None,
|
||||||
|
commands_run: str | None = None,
|
||||||
|
artifacts_created: str | None = None,
|
||||||
|
remaining_failures: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
sess = self._session()
|
sess = self._session()
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return "Error: complete_goal requires an active chat session."
|
return "Error: complete_goal requires an active chat session."
|
||||||
|
|
||||||
|
session_key = self._request_ctx.get().session_key if self._request_ctx.get() else None
|
||||||
|
observation = latest_verification_observation(session_key)
|
||||||
|
if (
|
||||||
|
observation is not None
|
||||||
|
and observation.analysis.status == "failed"
|
||||||
|
and not _has_meaningful_remaining_failures(remaining_failures)
|
||||||
|
):
|
||||||
|
return format_completion_gate_message(observation)
|
||||||
|
|
||||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||||
if not isinstance(prior, dict) or prior.get("status") != "active":
|
if not isinstance(prior, dict) or prior.get("status") != "active":
|
||||||
return "No active goal to complete."
|
return "No active goal to complete."
|
||||||
|
|
||||||
ended = _iso_now()
|
ended = _iso_now()
|
||||||
sess.metadata[GOAL_STATE_KEY] = {
|
completed = {
|
||||||
**prior,
|
**prior,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"completed_at": ended,
|
"completed_at": ended,
|
||||||
"recap": (recap or "").strip(),
|
"recap": (recap or "").strip(),
|
||||||
}
|
}
|
||||||
|
if verification_summary:
|
||||||
|
completed["verification_summary"] = verification_summary.strip()
|
||||||
|
if commands_run:
|
||||||
|
completed["commands_run"] = commands_run.strip()
|
||||||
|
if artifacts_created:
|
||||||
|
completed["artifacts_created"] = artifacts_created.strip()
|
||||||
|
if remaining_failures:
|
||||||
|
completed["remaining_failures"] = remaining_failures.strip()
|
||||||
|
sess.metadata[GOAL_STATE_KEY] = completed
|
||||||
discard_legacy_goal_state_key(sess.metadata)
|
discard_legacy_goal_state_key(sess.metadata)
|
||||||
self._sessions.save(sess)
|
self._sessions.save(sess)
|
||||||
|
clear_verification_observation(session_key)
|
||||||
await self._publish_goal_state_changed(sess.metadata)
|
await self._publish_goal_state_changed(sess.metadata)
|
||||||
tail = (recap or "").strip()
|
tail = (recap or "").strip()
|
||||||
if tail:
|
if tail:
|
||||||
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
||||||
return f"Goal marked complete ({ended})."
|
return f"Goal marked complete ({ended})."
|
||||||
|
|
||||||
|
|
||||||
|
def _has_meaningful_remaining_failures(value: str | None) -> bool:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
return bool(text and text not in {"none", "no", "n/a", "na", "no remaining failures"})
|
||||||
|
|||||||
+153
-20
@@ -6,14 +6,17 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import AliasChoices, Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_session_key
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
@@ -33,12 +36,19 @@ from nanobot.agent.tools.schema import (
|
|||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
||||||
from nanobot.security.workspace_policy import is_path_within
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
|
from nanobot.utils.helpers import build_structured_output_summary
|
||||||
|
|
||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
_DETACHED_EXIT_GRACE_S = 1.0 if _IS_WINDOWS else 0.2
|
||||||
|
|
||||||
|
|
||||||
# Policy note appended to recoverable workspace-boundary guard errors.
|
# Policy note appended to recoverable workspace-boundary guard errors.
|
||||||
@@ -55,6 +65,13 @@ class ExecToolConfig(Base):
|
|||||||
"""Shell exec tool configuration."""
|
"""Shell exec tool configuration."""
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||||
|
allow_local_service_access: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias=AliasChoices(
|
||||||
|
"allowLocalServiceAccess",
|
||||||
|
"allow_local_service_access",
|
||||||
|
),
|
||||||
|
) # allow shell commands to reach literal localhost/loopback services
|
||||||
path_prepend: str = ""
|
path_prepend: str = ""
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = ""
|
sandbox: str = ""
|
||||||
@@ -126,6 +143,16 @@ class _PreparedCommand:
|
|||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
|
detach=BooleanSchema(
|
||||||
|
description=(
|
||||||
|
"Run the command as a detached background process that can "
|
||||||
|
"survive after the agent finishes. Use for local servers, "
|
||||||
|
"dev servers, mock APIs, or other services that must remain "
|
||||||
|
"available for later commands or external verification."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class ExecTool(Tool):
|
class ExecTool(Tool):
|
||||||
@@ -149,6 +176,7 @@ class ExecTool(Tool):
|
|||||||
working_dir=ctx.workspace,
|
working_dir=ctx.workspace,
|
||||||
timeout=cfg.timeout,
|
timeout=cfg.timeout,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
allow_local_service_access=cfg.allow_local_service_access,
|
||||||
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
||||||
sandbox=cfg.sandbox,
|
sandbox=cfg.sandbox,
|
||||||
path_prepend=cfg.path_prepend,
|
path_prepend=cfg.path_prepend,
|
||||||
@@ -165,6 +193,7 @@ class ExecTool(Tool):
|
|||||||
deny_patterns: list[str] | None = None,
|
deny_patterns: list[str] | None = None,
|
||||||
allow_patterns: list[str] | None = None,
|
allow_patterns: list[str] | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
|
allow_local_service_access: bool = False,
|
||||||
webui_allow_local_service_access: bool = True,
|
webui_allow_local_service_access: bool = True,
|
||||||
allow_local_preview_access: bool | None = None,
|
allow_local_preview_access: bool | None = None,
|
||||||
sandbox: str = "",
|
sandbox: str = "",
|
||||||
@@ -197,6 +226,7 @@ class ExecTool(Tool):
|
|||||||
]
|
]
|
||||||
self.allow_patterns = allow_patterns or []
|
self.allow_patterns = allow_patterns or []
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
self.allow_local_service_access = allow_local_service_access
|
||||||
if allow_local_preview_access is not None:
|
if allow_local_preview_access is not None:
|
||||||
webui_allow_local_service_access = allow_local_preview_access
|
webui_allow_local_service_access = allow_local_preview_access
|
||||||
self.webui_allow_local_service_access = webui_allow_local_service_access
|
self.webui_allow_local_service_access = webui_allow_local_service_access
|
||||||
@@ -236,8 +266,11 @@ class ExecTool(Tool):
|
|||||||
"Use -y or --yes flags to avoid interactive prompts. "
|
"Use -y or --yes flags to avoid interactive prompts. "
|
||||||
"For long-running or interactive commands, pass yield_time_ms; "
|
"For long-running or interactive commands, pass yield_time_ms; "
|
||||||
"if the command keeps running, exec returns a session_id that can "
|
"if the command keeps running, exec returns a session_id that can "
|
||||||
"be polled or written to with write_stdin. Output is truncated at "
|
"be polled or written to with write_stdin. For services that "
|
||||||
"10 000 chars; timeout defaults to 60s."
|
"must remain available after you finish, pass detach=true instead "
|
||||||
|
"of yield_time_ms; detached output is written to a log file and "
|
||||||
|
"the tool returns a pid. Output is truncated at 10 000 chars; "
|
||||||
|
"timeout defaults to 60s."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -251,6 +284,7 @@ class ExecTool(Tool):
|
|||||||
login: bool | None = None, yield_time_ms: int | None = None,
|
login: bool | None = None, yield_time_ms: int | None = None,
|
||||||
max_output_chars: int | None = None,
|
max_output_chars: int | None = None,
|
||||||
max_output_tokens: int | None = None,
|
max_output_tokens: int | None = None,
|
||||||
|
detach: bool | None = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
command = command or cmd
|
command = command or cmd
|
||||||
@@ -264,10 +298,14 @@ class ExecTool(Tool):
|
|||||||
if isinstance(prepared, str):
|
if isinstance(prepared, str):
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
if detach:
|
||||||
|
return await self._execute_detached(prepared)
|
||||||
|
|
||||||
if yield_time_ms is not None:
|
if yield_time_ms is not None:
|
||||||
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
started_at = time.monotonic()
|
||||||
process = await self._spawn(
|
process = await self._spawn(
|
||||||
prepared.command,
|
prepared.command,
|
||||||
prepared.cwd,
|
prepared.cwd,
|
||||||
@@ -283,7 +321,15 @@ class ExecTool(Tool):
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
return f"Error: Command timed out after {prepared.timeout} seconds"
|
result = f"Error: Command timed out after {prepared.timeout} seconds"
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=None,
|
||||||
|
timed_out=True,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
@@ -301,17 +347,35 @@ class ExecTool(Tool):
|
|||||||
output_parts.append(f"\nExit code: {process.returncode}")
|
output_parts.append(f"\nExit code: {process.returncode}")
|
||||||
|
|
||||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||||
|
elapsed_s = max(0.0, time.monotonic() - started_at)
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=process.returncode,
|
||||||
|
)
|
||||||
|
|
||||||
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||||
if len(result) > max_len:
|
if len(result) > max_len:
|
||||||
half = max_len // 2
|
result = build_structured_output_summary(
|
||||||
result = (
|
"[tool output truncated]",
|
||||||
result[:half]
|
result,
|
||||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
max_chars=max_len,
|
||||||
+ result[-half:]
|
metadata=[
|
||||||
|
("original_size_chars", len(result)),
|
||||||
|
("exit_code", process.returncode),
|
||||||
|
("duration_s", f"{elapsed_s:.1f}"),
|
||||||
|
],
|
||||||
|
analysis=analysis,
|
||||||
|
guidance=(
|
||||||
|
"Use the structured summary first. Rerun a narrower "
|
||||||
|
"command, grep a specific failure, or inspect the "
|
||||||
|
"named artifact instead of rerunning broad noisy logs."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error executing command: {str(e)}"
|
return f"Error executing command: {str(e)}"
|
||||||
@@ -339,10 +403,71 @@ class ExecTool(Tool):
|
|||||||
MAX_OUTPUT_CHARS,
|
MAX_OUTPUT_CHARS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return format_session_poll(session_id, poll)
|
result = format_session_poll(session_id, poll)
|
||||||
|
if poll.done:
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=poll.exit_code,
|
||||||
|
timed_out=poll.timed_out,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Error executing command: {exc}"
|
return f"Error executing command: {exc}"
|
||||||
|
|
||||||
|
async def _execute_detached(self, prepared: _PreparedCommand) -> str:
|
||||||
|
log_dir = Path(prepared.cwd) / ".nanobot" / "exec-logs"
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = log_dir / f"detached-{uuid.uuid4().hex[:12]}.log"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error preparing detached command log directory: {exc}"
|
||||||
|
|
||||||
|
log_handle = None
|
||||||
|
try:
|
||||||
|
log_handle = open(log_path, "ab", buffering=0)
|
||||||
|
process = await self._spawn(
|
||||||
|
prepared.command,
|
||||||
|
prepared.cwd,
|
||||||
|
prepared.env,
|
||||||
|
prepared.shell_program,
|
||||||
|
prepared.login,
|
||||||
|
stdout=log_handle,
|
||||||
|
stderr=log_handle,
|
||||||
|
start_new_session=not _IS_WINDOWS,
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if _IS_WINDOWS else 0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error starting detached command: {exc}"
|
||||||
|
finally:
|
||||||
|
if log_handle is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
exit_code = await asyncio.wait_for(process.wait(), timeout=_DETACHED_EXIT_GRACE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return (
|
||||||
|
"Detached process started.\n"
|
||||||
|
f"pid: {process.pid}\n"
|
||||||
|
f"cwd: {prepared.cwd}\n"
|
||||||
|
f"log: {log_path}\n"
|
||||||
|
"Poll the log or run a health check to verify the service is ready."
|
||||||
|
)
|
||||||
|
|
||||||
|
log_text = ""
|
||||||
|
with suppress(Exception):
|
||||||
|
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if len(log_text) > 4000:
|
||||||
|
log_text = log_text[-4000:]
|
||||||
|
return (
|
||||||
|
f"Detached process exited immediately with code {exit_code}.\n"
|
||||||
|
f"log: {log_path}\n"
|
||||||
|
f"{log_text}"
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
||||||
"""Resolve the effective hard timeout in seconds (None = no limit).
|
"""Resolve the effective hard timeout in seconds (None = no limit).
|
||||||
|
|
||||||
@@ -464,6 +589,10 @@ class ExecTool(Tool):
|
|||||||
login: bool = False,
|
login: bool = False,
|
||||||
*,
|
*,
|
||||||
stdin: int = asyncio.subprocess.DEVNULL,
|
stdin: int = asyncio.subprocess.DEVNULL,
|
||||||
|
stdout: Any = asyncio.subprocess.PIPE,
|
||||||
|
stderr: Any = asyncio.subprocess.PIPE,
|
||||||
|
start_new_session: bool = False,
|
||||||
|
creationflags: int = 0,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
@@ -471,18 +600,20 @@ class ExecTool(Tool):
|
|||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
"powershell", "-NoProfile", "-Command", command,
|
"powershell", "-NoProfile", "-Command", command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
return await asyncio.create_subprocess_shell(
|
return await asyncio.create_subprocess_shell(
|
||||||
command,
|
command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
args = [shell_program]
|
args = [shell_program]
|
||||||
@@ -493,10 +624,11 @@ class ExecTool(Tool):
|
|||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
start_new_session=start_new_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -614,11 +746,12 @@ class ExecTool(Tool):
|
|||||||
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||||
|
|
||||||
from nanobot.security.network import contains_internal_url
|
from nanobot.security.network import contains_internal_url
|
||||||
|
allow_loopback = self.allow_local_service_access or current_scope_allows_loopback(
|
||||||
|
enabled=self.webui_allow_local_service_access,
|
||||||
|
)
|
||||||
if contains_internal_url(
|
if contains_internal_url(
|
||||||
cmd,
|
cmd,
|
||||||
allow_loopback=current_scope_allows_loopback(
|
allow_loopback=allow_loopback,
|
||||||
enabled=self.webui_allow_local_service_access,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
# The runner turns this marker into a non-retryable security hint.
|
# The runner turns this marker into a non-retryable security hint.
|
||||||
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Lightweight verification-result detection for coding workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
VerificationStatus = Literal["passed", "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VerificationAnalysis:
|
||||||
|
"""Structured summary of a command that appears to be verification."""
|
||||||
|
|
||||||
|
status: VerificationStatus
|
||||||
|
command: str
|
||||||
|
exit_code: int | None
|
||||||
|
failed_tests: tuple[str, ...] = ()
|
||||||
|
primary_errors: tuple[str, ...] = ()
|
||||||
|
missing_artifacts: tuple[str, ...] = ()
|
||||||
|
timed_out: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VerificationObservation:
|
||||||
|
"""Latest verification signal observed for a session."""
|
||||||
|
|
||||||
|
analysis: VerificationAnalysis
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
_OBSERVATIONS: dict[str, VerificationObservation] = {}
|
||||||
|
_SEQUENCE = 0
|
||||||
|
|
||||||
|
_TEST_COMMAND_RE = re.compile(
|
||||||
|
r"(?ix)"
|
||||||
|
r"("
|
||||||
|
r"\bpytest\b|\bpy\.test\b|\bunittest\b|\bnosetests\b|"
|
||||||
|
r"\btest_outputs\.py\b|\brun_tests?(?:\.sh|\.py)?\b|"
|
||||||
|
r"\bnpm\s+(?:run\s+)?test\b|\byarn\s+test\b|\bpnpm\s+test\b|"
|
||||||
|
r"\bcargo\s+test\b|\bgo\s+test\b|\bctest\b|"
|
||||||
|
r"\bmake\s+(?:[^;&|]*\s+)?test\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_CHECK_COMMAND_RE = re.compile(
|
||||||
|
r"(?ix)"
|
||||||
|
r"("
|
||||||
|
r"\bcmp\b|"
|
||||||
|
r"\bdiff\b|"
|
||||||
|
r"\bsha(?:1|224|256|384|512)?sum\b|"
|
||||||
|
r"\bmd5sum\b|"
|
||||||
|
r"\bgcc\b.*(?:&&|;).*\./|"
|
||||||
|
r"\bclang\b.*(?:&&|;).*\./|"
|
||||||
|
r"\bpython3?\b.*<<['\"]?PY\b.*\bassert\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_COMPARISON_COMMAND_RE = re.compile(r"(?i)\b(?:cmp|diff)\b")
|
||||||
|
_FAILURE_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"^FAILED\s+|"
|
||||||
|
r"\b\d+\s+failed\b|"
|
||||||
|
r"\bAssertionError\b|"
|
||||||
|
r"\bFileNotFoundError\b|"
|
||||||
|
r"\bTimeoutError\b|"
|
||||||
|
r"\bcommand not found\b|"
|
||||||
|
r"\bError:\s+Command timed out\b|"
|
||||||
|
r"\bFAILURES?\b|"
|
||||||
|
r"\bTEST FAILED\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_SUCCESS_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b\d+\s+passed\b|"
|
||||||
|
r"\bOK\b|"
|
||||||
|
r"\bTEST PASSED\b|"
|
||||||
|
r"\bExit code:\s*0\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_SUCCESS_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b(?:cmp|diff|test|verify)_exit:\s*0\b|"
|
||||||
|
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*0\s*$"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_FAILURE_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b(?:cmp|diff|test|verify)_exit:\s*[1-9]\d*\b|"
|
||||||
|
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*[1-9]\d*\s*$"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_FAILED_TEST_RE = re.compile(r"(?m)^FAILED\s+([^\s]+)")
|
||||||
|
_PYTEST_SHORT_RE = re.compile(r"(?m)^_{3,}\s+([A-Za-z0-9_./:-]+)\s+_{3,}$")
|
||||||
|
_ERROR_LINE_RE = re.compile(
|
||||||
|
r"(?m)"
|
||||||
|
r"^\s*(?:E\s+)?("
|
||||||
|
r"(?:AssertionError|FileNotFoundError|TimeoutError|ValueError|TypeError|RuntimeError)"
|
||||||
|
r"(?::[^\n]*)?|"
|
||||||
|
r"assert\s+[^\n]+|"
|
||||||
|
r"[^:\n]+:\s+line\s+\d+:\s+[^:\n]+:\s+command not found|"
|
||||||
|
r"Error:\s+[^\n]+|"
|
||||||
|
r"TEST FAILED[^\n]*"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_MISSING_PATH_RE = re.compile(
|
||||||
|
r"(?i)"
|
||||||
|
r"(?:No such file or directory:\s*['\"]([^'\"]+)['\"]|"
|
||||||
|
r"(?:file|path)\s+([^\s'\"]+)\s+does not exist|"
|
||||||
|
r"cannot open file\s+['\"]([^'\"]+)['\"])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_verification_result(
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
output: str,
|
||||||
|
exit_code: int | None,
|
||||||
|
timed_out: bool = False,
|
||||||
|
) -> VerificationAnalysis | None:
|
||||||
|
"""Return a verification summary when a command/output looks like a test."""
|
||||||
|
|
||||||
|
command = " ".join((command or "").split())
|
||||||
|
looks_like_test_command = bool(_TEST_COMMAND_RE.search(command))
|
||||||
|
looks_like_artifact_check = bool(_ARTIFACT_CHECK_COMMAND_RE.search(command))
|
||||||
|
looks_like_comparison_command = bool(_COMPARISON_COMMAND_RE.search(command))
|
||||||
|
looks_like_verification = looks_like_test_command or looks_like_artifact_check
|
||||||
|
failure_seen = bool(_FAILURE_RE.search(output))
|
||||||
|
success_seen = bool(_SUCCESS_RE.search(output))
|
||||||
|
artifact_success_seen = bool(_ARTIFACT_SUCCESS_RE.search(output)) and (
|
||||||
|
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*0\b", output, flags=re.I))
|
||||||
|
)
|
||||||
|
artifact_failure_seen = bool(_ARTIFACT_FAILURE_RE.search(output)) and (
|
||||||
|
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*[1-9]\d*\b", output, flags=re.I))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not looks_like_test_command and not failure_seen:
|
||||||
|
if not (looks_like_artifact_check and artifact_success_seen and exit_code == 0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (
|
||||||
|
(timed_out and looks_like_verification)
|
||||||
|
or (exit_code not in (None, 0) and (looks_like_verification or failure_seen))
|
||||||
|
or failure_seen
|
||||||
|
or artifact_failure_seen
|
||||||
|
):
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
failed_tests=_unique(_FAILED_TEST_RE.findall(output), limit=8),
|
||||||
|
primary_errors=_extract_primary_errors(output),
|
||||||
|
missing_artifacts=_extract_missing_artifacts(output),
|
||||||
|
timed_out=timed_out,
|
||||||
|
)
|
||||||
|
|
||||||
|
if looks_like_test_command and exit_code == 0 and success_seen:
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if looks_like_artifact_check and exit_code == 0 and artifact_success_seen:
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def append_verification_feedback(output: str, analysis: VerificationAnalysis | None) -> str:
|
||||||
|
"""Append model-facing feedback for failed verification results."""
|
||||||
|
|
||||||
|
if analysis is None or analysis.status != "failed":
|
||||||
|
return output
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"[Verification Feedback]",
|
||||||
|
"Verification status: failed.",
|
||||||
|
"Do not call complete_goal or present the task as finished until this is fixed and a verification passes.",
|
||||||
|
]
|
||||||
|
if analysis.command:
|
||||||
|
lines.append(f"Command: {analysis.command[:240]}")
|
||||||
|
if analysis.exit_code is not None:
|
||||||
|
lines.append(f"Exit code: {analysis.exit_code}")
|
||||||
|
if analysis.timed_out:
|
||||||
|
lines.append("Failure type: command timeout")
|
||||||
|
if analysis.failed_tests:
|
||||||
|
lines.append("Failed tests:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||||
|
if analysis.primary_errors:
|
||||||
|
lines.append("Primary errors:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||||
|
if analysis.missing_artifacts:
|
||||||
|
lines.append("Missing artifacts:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||||
|
lines.append("Next action: inspect the failing assertion, fix the implementation or artifact, then rerun the most specific verification command.")
|
||||||
|
lines.append("[/Verification Feedback]")
|
||||||
|
return output.rstrip() + "\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def record_verification_observation(session_key: str | None, analysis: VerificationAnalysis | None) -> None:
|
||||||
|
"""Remember the latest verification signal for a session."""
|
||||||
|
|
||||||
|
if not session_key or analysis is None:
|
||||||
|
return
|
||||||
|
global _SEQUENCE
|
||||||
|
_SEQUENCE += 1
|
||||||
|
_OBSERVATIONS[session_key] = VerificationObservation(
|
||||||
|
analysis=analysis,
|
||||||
|
sequence=_SEQUENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_verification_observation(session_key: str | None) -> VerificationObservation | None:
|
||||||
|
if not session_key:
|
||||||
|
return None
|
||||||
|
return _OBSERVATIONS.get(session_key)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_verification_observation(session_key: str | None) -> None:
|
||||||
|
if session_key:
|
||||||
|
_OBSERVATIONS.pop(session_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def format_completion_gate_message(observation: VerificationObservation) -> str:
|
||||||
|
"""Build the complete_goal soft-gate message for unresolved failures."""
|
||||||
|
|
||||||
|
analysis = observation.analysis
|
||||||
|
lines = [
|
||||||
|
"Recent verification appears to have failed, so the goal is not marked complete yet.",
|
||||||
|
"Continue fixing the task and rerun verification before completing.",
|
||||||
|
]
|
||||||
|
if analysis.command:
|
||||||
|
lines.append(f"Last failed verification command: {analysis.command[:240]}")
|
||||||
|
if analysis.failed_tests:
|
||||||
|
lines.append("Failed tests: " + ", ".join(analysis.failed_tests[:5]))
|
||||||
|
if analysis.primary_errors:
|
||||||
|
lines.append("Primary error: " + analysis.primary_errors[0])
|
||||||
|
if analysis.missing_artifacts:
|
||||||
|
lines.append("Missing artifact: " + analysis.missing_artifacts[0])
|
||||||
|
lines.append(
|
||||||
|
"If you are intentionally stopping with known failures, call complete_goal again with remaining_failures describing them honestly."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_primary_errors(output: str) -> tuple[str, ...]:
|
||||||
|
candidates: list[str] = []
|
||||||
|
for match in _ERROR_LINE_RE.findall(output):
|
||||||
|
text = " ".join(match.split())
|
||||||
|
if text and text not in candidates:
|
||||||
|
candidates.append(text[:240])
|
||||||
|
if len(candidates) >= 8:
|
||||||
|
break
|
||||||
|
if not candidates:
|
||||||
|
for match in _PYTEST_SHORT_RE.findall(output):
|
||||||
|
text = " ".join(match.split())
|
||||||
|
if text and text not in candidates:
|
||||||
|
candidates.append(text[:240])
|
||||||
|
if len(candidates) >= 4:
|
||||||
|
break
|
||||||
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_missing_artifacts(output: str) -> tuple[str, ...]:
|
||||||
|
paths: list[str] = []
|
||||||
|
for groups in _MISSING_PATH_RE.findall(output):
|
||||||
|
path = next((item for item in groups if item), "")
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path[:240])
|
||||||
|
if len(paths) >= 8:
|
||||||
|
break
|
||||||
|
return tuple(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(items: list[str], *, limit: int) -> tuple[str, ...]:
|
||||||
|
out: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
text = " ".join(item.split())
|
||||||
|
if text and text not in out:
|
||||||
|
out.append(text[:240])
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return tuple(out)
|
||||||
@@ -626,7 +626,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
|
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
|
||||||
|
|
||||||
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap plus verification_summary / commands_run / artifacts_created when applicable. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
{goal}
|
{goal}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -26,6 +27,25 @@ from nanobot.providers.openai_responses import (
|
|||||||
|
|
||||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
DEFAULT_ORIGINATOR = "nanobot"
|
DEFAULT_ORIGINATOR = "nanobot"
|
||||||
|
_RESPONSE_FAILED_PREFIX = "Response failed:"
|
||||||
|
_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||||
|
"overloaded",
|
||||||
|
"overloaded_error",
|
||||||
|
"rate_limit_exceeded",
|
||||||
|
"request_limit_exceeded",
|
||||||
|
"requests_limit_exceeded",
|
||||||
|
"server_error",
|
||||||
|
"server_is_overloaded",
|
||||||
|
"service_unavailable",
|
||||||
|
"temporarily_unavailable",
|
||||||
|
"too_many_requests",
|
||||||
|
})
|
||||||
|
_NON_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||||
|
"content_filter",
|
||||||
|
"content_policy_violation",
|
||||||
|
"cyber_policy",
|
||||||
|
"safety_violation",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class OpenAICodexProvider(LLMProvider):
|
class OpenAICodexProvider(LLMProvider):
|
||||||
@@ -246,6 +266,8 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
|
|
||||||
status_code = getattr(exc, "status_code", None)
|
status_code = getattr(exc, "status_code", None)
|
||||||
error_kind: str | None = None
|
error_kind: str | None = None
|
||||||
|
error_type = getattr(exc, "error_type", None)
|
||||||
|
error_code = getattr(exc, "error_code", None)
|
||||||
default_detail: str | None = None
|
default_detail: str | None = None
|
||||||
should_retry: bool | None = getattr(exc, "should_retry", None)
|
should_retry: bool | None = getattr(exc, "should_retry", None)
|
||||||
|
|
||||||
@@ -265,12 +287,20 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
error_kind = "http"
|
error_kind = "http"
|
||||||
default_detail = "HTTP request failed"
|
default_detail = "HTTP request failed"
|
||||||
|
|
||||||
|
failed_type, failed_code = _extract_response_failed_error(detail)
|
||||||
|
if failed_type or failed_code:
|
||||||
|
error_kind = error_kind or "provider"
|
||||||
|
error_type = failed_type or error_type
|
||||||
|
error_code = failed_code or error_code
|
||||||
|
if should_retry is None:
|
||||||
|
should_retry = _should_retry_response_failed(error_type, error_code, detail)
|
||||||
|
|
||||||
if status_code is not None and should_retry is None:
|
if status_code is not None and should_retry is None:
|
||||||
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||||
should_retry = _should_retry_status(
|
should_retry = _should_retry_status(
|
||||||
int(status_code),
|
int(status_code),
|
||||||
getattr(exc, "error_type", None),
|
error_type,
|
||||||
getattr(exc, "error_code", None),
|
error_code,
|
||||||
retry_content,
|
retry_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -283,13 +313,56 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
error_status_code=int(status_code) if status_code is not None else None,
|
error_status_code=int(status_code) if status_code is not None else None,
|
||||||
error_kind=error_kind,
|
error_kind=error_kind,
|
||||||
error_type=getattr(exc, "error_type", None),
|
error_type=error_type,
|
||||||
error_code=getattr(exc, "error_code", None),
|
error_code=error_code,
|
||||||
error_retry_after_s=retry_after,
|
error_retry_after_s=retry_after,
|
||||||
error_should_retry=should_retry,
|
error_should_retry=should_retry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_response_failed_error(detail: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract provider semantic error fields from Responses SSE failures."""
|
||||||
|
if _RESPONSE_FAILED_PREFIX not in detail:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
payload = detail.split(_RESPONSE_FAILED_PREFIX, 1)[1].strip()
|
||||||
|
if not payload:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
parsed: Any = None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
parsed = ast.literal_eval(payload)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
error_type, error_code = LLMProvider._extract_error_type_code(parsed or payload)
|
||||||
|
return error_type, error_code
|
||||||
|
|
||||||
|
|
||||||
|
def _should_retry_response_failed(
|
||||||
|
error_type: str | None,
|
||||||
|
error_code: str | None,
|
||||||
|
detail: str,
|
||||||
|
) -> bool | None:
|
||||||
|
semantic_tokens = {
|
||||||
|
token for token in (
|
||||||
|
LLMProvider._normalize_error_token(error_type),
|
||||||
|
LLMProvider._normalize_error_token(error_code),
|
||||||
|
)
|
||||||
|
if token is not None
|
||||||
|
}
|
||||||
|
if any(token in _NON_RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||||
|
return False
|
||||||
|
if any(token in _RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||||
|
return True
|
||||||
|
if LLMProvider._is_transient_error(detail):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
||||||
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
||||||
if response.error_status_code is not None:
|
if response.error_status_code is not None:
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ class SessionManager:
|
|||||||
the most recent writes.
|
the most recent writes.
|
||||||
"""
|
"""
|
||||||
path = self._get_session_path(session.key)
|
path = self._get_session_path(session.key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Those belong to the execution phase after the marker is set.
|
|||||||
|
|
||||||
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
|
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
|
||||||
|
|
||||||
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). For coding or file-producing tasks, include **`verification_summary`**, **`commands_run`**, and **`artifacts_created`** when possible; if stopping with known unresolved issues, fill **`remaining_failures`** honestly. Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
||||||
|
|
||||||
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
|
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Use this when the goal is to **build or reshape a codebase** (app, service, tool
|
|||||||
|
|
||||||
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
|
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
|
||||||
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
|
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
|
||||||
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter.
|
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. Before `complete_goal`, run the smallest reliable verification you can and summarize it in `verification_summary`.
|
||||||
|
|
||||||
## Look things up instead of guessing
|
## Look things up instead of guessing
|
||||||
|
|
||||||
|
|||||||
+100
-21
@@ -290,7 +290,8 @@ def current_time_str(timezone: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
|
||||||
|
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
|
||||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||||
@@ -404,22 +405,106 @@ def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _render_tool_result_reference(
|
def build_structured_output_summary(
|
||||||
filepath: Path,
|
title: str,
|
||||||
|
text: str,
|
||||||
*,
|
*,
|
||||||
original_size: int,
|
max_chars: int,
|
||||||
preview: str,
|
metadata: list[tuple[str, Any]] | None = None,
|
||||||
truncated_preview: bool,
|
analysis: Any | None = None,
|
||||||
|
guidance: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = (
|
"""Return a compact, structured head/tail summary for oversized tool output."""
|
||||||
f"[tool output persisted]\n"
|
|
||||||
f"Full output saved to: {filepath}\n"
|
if max_chars <= 0:
|
||||||
f"Original size: {original_size} chars\n"
|
return text
|
||||||
f"Preview:\n{preview}"
|
edge_chars = min(
|
||||||
|
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
|
||||||
|
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
head = text[:edge_chars]
|
||||||
|
if len(text) > edge_chars * 2:
|
||||||
|
tail: str | None = text[-edge_chars:]
|
||||||
|
omitted_middle_chars = len(text) - len(head) - len(tail)
|
||||||
|
else:
|
||||||
|
tail = None
|
||||||
|
omitted_middle_chars = 0
|
||||||
|
result = _render_structured_output_summary(
|
||||||
|
title,
|
||||||
|
metadata=metadata or [],
|
||||||
|
guidance=guidance,
|
||||||
|
analysis=analysis,
|
||||||
|
head=head,
|
||||||
|
tail=tail,
|
||||||
|
omitted_middle_chars=omitted_middle_chars,
|
||||||
|
)
|
||||||
|
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
|
||||||
|
return truncate_text(result, max_chars)
|
||||||
|
overflow = len(result) - max_chars
|
||||||
|
edge_chars = max(
|
||||||
|
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
|
||||||
|
edge_chars - max(overflow // 2 + 1, 16),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_structured_output_summary(
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
metadata: list[tuple[str, Any]],
|
||||||
|
guidance: str | None,
|
||||||
|
analysis: Any | None,
|
||||||
|
head: str,
|
||||||
|
tail: str | None,
|
||||||
|
omitted_middle_chars: int,
|
||||||
|
) -> str:
|
||||||
|
lines = [title]
|
||||||
|
lines.extend(f"{key}: {value}" for key, value in metadata)
|
||||||
|
if omitted_middle_chars:
|
||||||
|
lines.append(f"truncation: {omitted_middle_chars:,} chars truncated from the middle")
|
||||||
|
if guidance:
|
||||||
|
lines.append(f"guidance: {guidance}")
|
||||||
|
lines.extend(_verification_summary_lines(analysis))
|
||||||
|
lines.extend(["head:", head])
|
||||||
|
if tail is not None:
|
||||||
|
lines.extend(["tail:", tail])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _verification_summary_lines(analysis: Any | None) -> list[str]:
|
||||||
|
if analysis is None or getattr(analysis, "status", None) != "failed":
|
||||||
|
return []
|
||||||
|
lines = ["verification_status: failed"]
|
||||||
|
if getattr(analysis, "timed_out", False):
|
||||||
|
lines.append("failure_type: command timeout")
|
||||||
|
if getattr(analysis, "failed_tests", ()):
|
||||||
|
lines.append("failed_tests:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||||
|
if getattr(analysis, "primary_errors", ()):
|
||||||
|
lines.append("primary_errors:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||||
|
if getattr(analysis, "missing_artifacts", ()):
|
||||||
|
lines.append("missing_artifacts:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
|
||||||
|
return build_structured_output_summary(
|
||||||
|
"[tool output persisted]",
|
||||||
|
text,
|
||||||
|
max_chars=max_chars,
|
||||||
|
metadata=[
|
||||||
|
("tool_output_id", filepath.stem),
|
||||||
|
("original_size_chars", len(text)),
|
||||||
|
("storage", "internal audit artifact"),
|
||||||
|
],
|
||||||
|
guidance=(
|
||||||
|
"Use this head/tail summary first. Avoid reading persisted "
|
||||||
|
"tool-output files wholesale; rerun a narrower command when "
|
||||||
|
"more detail is needed."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if truncated_preview:
|
|
||||||
result += "\n...\n(Read the saved file if you need the full output.)"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _bucket_mtime(path: Path) -> float:
|
def _bucket_mtime(path: Path) -> float:
|
||||||
@@ -494,13 +579,7 @@ def maybe_persist_tool_result(
|
|||||||
else:
|
else:
|
||||||
_write_text_atomic(path, text_payload)
|
_write_text_atomic(path, text_payload)
|
||||||
|
|
||||||
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS]
|
return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
|
||||||
return _render_tool_result_reference(
|
|
||||||
path,
|
|
||||||
original_size=len(text_payload),
|
|
||||||
preview=preview,
|
|
||||||
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def split_message(content: str, max_len: int = 2000) -> list[str]:
|
def split_message(content: str, max_len: int = 2000) -> list[str]:
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
|||||||
"objective using your tools, or call complete_goal if the work is truly finished."
|
"objective using your tools, or call complete_goal if the work is truly finished."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RUNTIME_BUDGET_CONVERGENCE_PROMPT = """\
|
||||||
|
[Runtime Budget Notice]
|
||||||
|
You have used {used_iterations} of {max_iterations} model/tool iterations for this turn. \
|
||||||
|
{remaining_iterations} iteration(s) remain before NanoBot must finalize without more tools.
|
||||||
|
|
||||||
|
Switch to convergence mode: stop broad exploration, choose the smallest high-signal command or edit, \
|
||||||
|
verify the likely solution, and preserve enough budget for a final answer. For coding or \
|
||||||
|
file-producing tasks, do not mark the work complete until the smallest reliable verification passes, \
|
||||||
|
or clearly state remaining failures.
|
||||||
|
[/Runtime Budget Notice]"""
|
||||||
|
|
||||||
|
RUNTIME_BUDGET_FINAL_PROMPT = """\
|
||||||
|
[Runtime Budget Notice]
|
||||||
|
Only {remaining_iterations} of {max_iterations} model/tool iteration(s) remain before NanoBot must \
|
||||||
|
finalize without more tools.
|
||||||
|
|
||||||
|
Finalize the solution path now: avoid new broad searches or builds unless essential, make the \
|
||||||
|
smallest final fix or artifact, run one targeted verification if possible, then answer honestly with \
|
||||||
|
the evidence or remaining failures.
|
||||||
|
[/Runtime Budget Notice]"""
|
||||||
|
|
||||||
|
|
||||||
def empty_tool_result_message(tool_name: str) -> str:
|
def empty_tool_result_message(tool_name: str) -> str:
|
||||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||||
@@ -88,6 +109,25 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
|||||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_budget_notice_message(
|
||||||
|
*,
|
||||||
|
level: int,
|
||||||
|
max_iterations: int,
|
||||||
|
used_iterations: int,
|
||||||
|
remaining_iterations: int,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Prompt the model to converge as the generic tool-iteration budget runs low."""
|
||||||
|
template = RUNTIME_BUDGET_FINAL_PROMPT if level >= 2 else RUNTIME_BUDGET_CONVERGENCE_PROMPT
|
||||||
|
return {
|
||||||
|
"role": "user",
|
||||||
|
"content": template.format(
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
used_iterations=used_iterations,
|
||||||
|
remaining_iterations=remaining_iterations,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||||
"""Stable signature for repeated external lookups we want to throttle."""
|
"""Stable signature for repeated external lookups we want to throttle."""
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
|
|||||||
assert result.final_content == "done"
|
assert result.final_content == "done"
|
||||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||||
assert "[tool output persisted]" in tool_message["content"]
|
assert "[tool output persisted]" in tool_message["content"]
|
||||||
assert "tool-results" in tool_message["content"]
|
assert "tool_output_id: call_big" in tool_message["content"]
|
||||||
|
assert "original_size_chars: 20000" in tool_message["content"]
|
||||||
|
assert "head:" in tool_message["content"]
|
||||||
|
assert "tail:" in tool_message["content"]
|
||||||
|
assert "Read the saved file" not in tool_message["content"]
|
||||||
|
assert str(tmp_path) not in tool_message["content"]
|
||||||
|
assert len(tool_message["content"]) <= 2048
|
||||||
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +82,8 @@ def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "[tool output persisted]" in persisted
|
assert "[tool output persisted]" in persisted
|
||||||
|
assert "tool_output_id: call_big" in persisted
|
||||||
|
assert "tool-results" not in persisted
|
||||||
assert not old_bucket.exists()
|
assert not old_bucket.exists()
|
||||||
assert recent_bucket.exists()
|
assert recent_bucket.exists()
|
||||||
assert (root / "current_session" / "call_big.txt").exists()
|
assert (root / "current_session" / "call_big.txt").exists()
|
||||||
|
|||||||
@@ -358,3 +358,79 @@ async def test_runner_blocks_repeated_external_fetches():
|
|||||||
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
||||||
][0]
|
][0]
|
||||||
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_adds_budget_notice_near_long_tool_budget():
|
||||||
|
provider = MagicMock()
|
||||||
|
captured_final_call: list[dict] = []
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
async def chat_with_retry(*, messages, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] <= 16:
|
||||||
|
return LLMResponse(
|
||||||
|
content="working",
|
||||||
|
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
captured_final_call[:] = messages
|
||||||
|
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
provider.chat_with_retry = chat_with_retry
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(return_value="tool result")
|
||||||
|
|
||||||
|
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "finish a large task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=20,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.final_content == "done"
|
||||||
|
notices = [
|
||||||
|
msg["content"]
|
||||||
|
for msg in captured_final_call
|
||||||
|
if msg.get("role") == "user" and "[Runtime Budget Notice]" in str(msg.get("content"))
|
||||||
|
]
|
||||||
|
assert len(notices) == 1
|
||||||
|
assert "15 of 20 model/tool iterations" in notices[0]
|
||||||
|
assert "Switch to convergence mode" in notices[0]
|
||||||
|
assert tools.execute.await_count == 16
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_budget_notice_does_not_affect_short_runs():
|
||||||
|
provider = MagicMock()
|
||||||
|
captured_final_call: list[dict] = []
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
async def chat_with_retry(*, messages, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] <= 2:
|
||||||
|
return LLMResponse(
|
||||||
|
content="working",
|
||||||
|
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
captured_final_call[:] = messages
|
||||||
|
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
provider.chat_with_retry = chat_with_retry
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(return_value="tool result")
|
||||||
|
|
||||||
|
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "small task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=4,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.final_content == "done"
|
||||||
|
assert all("[Runtime Budget Notice]" not in str(msg.get("content")) for msg in captured_final_call)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for atomic session save and corrupt-file repair."""
|
"""Tests for atomic session save and corrupt-file repair."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -36,6 +37,17 @@ class TestAtomicSave:
|
|||||||
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
||||||
assert tmp_files == []
|
assert tmp_files == []
|
||||||
|
|
||||||
|
def test_save_recreates_deleted_sessions_dir(self, tmp_path: Path):
|
||||||
|
mgr = SessionManager(tmp_path)
|
||||||
|
shutil.rmtree(mgr.sessions_dir)
|
||||||
|
|
||||||
|
session = Session(key="test:recreate")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
mgr.save(session)
|
||||||
|
|
||||||
|
path = mgr._get_session_path("test:recreate")
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
||||||
mgr = SessionManager(tmp_path)
|
mgr = SessionManager(tmp_path)
|
||||||
session = Session(key="test:fail")
|
session = Session(key="test:fail")
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_pytest_failure_extracts_actionable_summary():
|
||||||
|
output = """\
|
||||||
|
FAILED ../tests/test_outputs.py::test_regex_matches_dates - AssertionError: Expected dates
|
||||||
|
E AssertionError: Expected ['2025-01-09'], but got ['bad']
|
||||||
|
E FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'
|
||||||
|
============================== 1 failed in 0.05s ===============================
|
||||||
|
Exit code: 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
output=output,
|
||||||
|
exit_code=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
assert analysis.failed_tests == ("../tests/test_outputs.py::test_regex_matches_dates",)
|
||||||
|
assert any("AssertionError" in item for item in analysis.primary_errors)
|
||||||
|
assert "/app/out.txt" in analysis.missing_artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_verification_feedback_tells_agent_not_to_finish():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="python /app/test_outputs.py",
|
||||||
|
output="FAILED test_outputs.py::test_file\nAssertionError: missing\nExit code: 1",
|
||||||
|
exit_code=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = append_verification_feedback("raw output\nExit code: 1", analysis)
|
||||||
|
|
||||||
|
assert "[Verification Feedback]" in feedback
|
||||||
|
assert "Do not call complete_goal" in feedback
|
||||||
|
assert "Next action" in feedback
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_passing_test_records_success_without_feedback():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="pytest",
|
||||||
|
output="============================== 3 passed in 0.10s ==============================\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
assert append_verification_feedback("ok", analysis) == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_command_not_found_as_failed_check():
|
||||||
|
output = """\
|
||||||
|
STDERR:
|
||||||
|
/usr/bin/bash: line 1: python3: command not found
|
||||||
|
|
||||||
|
Exit code: 127
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="python3 - <<'PY'\nprint('quick verification')\nPY",
|
||||||
|
output=output,
|
||||||
|
exit_code=127,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
assert any("command not found" in item for item in analysis.primary_errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_artifact_comparison_success_records_pass():
|
||||||
|
output = """\
|
||||||
|
run_exit:0
|
||||||
|
0d115b98 /app/image.ppm
|
||||||
|
0d115b98 /tmp/orig.ppm
|
||||||
|
cmp_exit:0
|
||||||
|
7 21 1024
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"cd /usr/bin && gcc -static -o /app/reversed_final /app/mystery.c -lm "
|
||||||
|
"&& (cd /app && ./reversed_final >/tmp/final_out 2>/tmp/final_err); "
|
||||||
|
"sha256sum /app/image.ppm /tmp/orig.ppm; "
|
||||||
|
"cmp -s /app/image.ppm /tmp/orig.ppm; echo cmp_exit:$?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
assert append_verification_feedback("ok", analysis) == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_plain_checksum_without_success_marker_is_ignored():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="sha256sum /app/image.ppm /tmp/orig.ppm",
|
||||||
|
output="0d115b98 /app/image.ppm\n0d115b98 /tmp/orig.ppm\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_named_comparison_markers_record_pass():
|
||||||
|
output = """\
|
||||||
|
ppm:0
|
||||||
|
stderr:0
|
||||||
|
stdout:0
|
||||||
|
4 26 1011
|
||||||
|
1821 mystery.c
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"gcc -static -O2 -o reversed mystery.c -lm\n"
|
||||||
|
"./reversed > vrout.txt 2> vrerr.txt\n"
|
||||||
|
"cp image.ppm rev.ppm\n"
|
||||||
|
"./mystery > voout.txt 2> voerr.txt\n"
|
||||||
|
"cmp image.ppm rev.ppm\n"
|
||||||
|
"printf 'ppm:%s\\n' $?\n"
|
||||||
|
"cmp voerr.txt vrerr.txt\n"
|
||||||
|
"printf 'stderr:%s\\n' $?\n"
|
||||||
|
"cmp voout.txt vrout.txt\n"
|
||||||
|
"printf 'stdout:%s\\n' $?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_named_comparison_marker_failure_records_failed():
|
||||||
|
output = """\
|
||||||
|
ppm:0
|
||||||
|
stderr:1
|
||||||
|
stdout:0
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"cmp image.ppm rev.ppm; printf 'ppm:%s\\n' $?; "
|
||||||
|
"cmp voerr.txt vrerr.txt; printf 'stderr:%s\\n' $?; "
|
||||||
|
"cmp voout.txt vrout.txt; printf 'stdout:%s\\n' $?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_plain_run_status_marker_without_comparison_is_ignored():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="gcc -static -O2 -o reversed mystery.c -lm && ./reversed",
|
||||||
|
output="rc:0\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is None
|
||||||
@@ -13,6 +13,11 @@ from nanobot.agent.tools.long_task import (
|
|||||||
CompleteGoalTool,
|
CompleteGoalTool,
|
||||||
LongTaskTool,
|
LongTaskTool,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
VerificationAnalysis,
|
||||||
|
clear_verification_observation,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
@@ -192,6 +197,66 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
|
|||||||
assert "No active" in out
|
assert "No active" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_goal_blocks_unresolved_verification_failure(tmp_path):
|
||||||
|
sm = SessionManager(tmp_path)
|
||||||
|
lt, cg = _tools(sm)
|
||||||
|
await lt.execute(goal="Fix the tests")
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=1,
|
||||||
|
failed_tests=("test_outputs.py::test_output",),
|
||||||
|
primary_errors=("AssertionError: wrong output",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
out = await cg.execute(recap="Done.")
|
||||||
|
|
||||||
|
assert "not marked complete" in out
|
||||||
|
assert "test_outputs.py::test_output" in out
|
||||||
|
assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["status"] == "active"
|
||||||
|
clear_verification_observation("websocket:c1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_goal_allows_after_later_successful_verification(tmp_path):
|
||||||
|
sm = SessionManager(tmp_path)
|
||||||
|
lt, cg = _tools(sm)
|
||||||
|
await lt.execute(goal="Fix the tests")
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=1,
|
||||||
|
failed_tests=("test_outputs.py::test_output",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
out = await cg.execute(
|
||||||
|
recap="Done.",
|
||||||
|
verification_summary="pytest /tests/test_outputs.py passed",
|
||||||
|
commands_run="pytest /tests/test_outputs.py",
|
||||||
|
artifacts_created="/app/out.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "marked complete" in out
|
||||||
|
blob = sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]
|
||||||
|
assert blob["status"] == "completed"
|
||||||
|
assert blob["verification_summary"] == "pytest /tests/test_outputs.py passed"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
|
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
|
||||||
sm = SessionManager(tmp_path)
|
sm = SessionManager(tmp_path)
|
||||||
|
|||||||
@@ -246,3 +246,16 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
|
|||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.tools.webui_allow_local_service_access is False
|
assert config.tools.webui_allow_local_service_access is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_accepts_exec_local_service_access(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"tools": {"exec": {"allowLocalServiceAccess": True}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
assert config.tools.exec.allow_local_service_access is True
|
||||||
|
assert not hasattr(config.tools, "allow_local_service_access")
|
||||||
|
|||||||
@@ -303,6 +303,37 @@ async def test_codex_http_error_preserves_status_and_retry_after(monkeypatch) ->
|
|||||||
assert response.error_should_retry is True
|
assert response.error_should_retry is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_response_failed_server_error_is_retryable() -> None:
|
||||||
|
response = _codex_error_response(
|
||||||
|
RuntimeError(
|
||||||
|
"Response failed: {'type': 'server_error', 'code': 'server_error', "
|
||||||
|
"'message': 'The server had an error while processing your request.'}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.finish_reason == "error"
|
||||||
|
assert response.error_kind == "provider"
|
||||||
|
assert response.error_type == "server_error"
|
||||||
|
assert response.error_code == "server_error"
|
||||||
|
assert response.error_should_retry is True
|
||||||
|
assert provider_base.LLMProvider._is_transient_response(response) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_response_failed_cyber_policy_is_not_retryable() -> None:
|
||||||
|
response = _codex_error_response(
|
||||||
|
RuntimeError(
|
||||||
|
"Response failed: {'type': 'invalid_request_error', 'code': 'cyber_policy', "
|
||||||
|
"'message': 'Request denied.'}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.error_kind == "provider"
|
||||||
|
assert response.error_type == "invalid_request_error"
|
||||||
|
assert response.error_code == "cyber_policy"
|
||||||
|
assert response.error_should_retry is False
|
||||||
|
assert provider_base.LLMProvider._is_transient_response(response) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_codex_http_diagnostic_log_omits_raw_body(monkeypatch) -> None:
|
async def test_codex_http_diagnostic_log_omits_raw_body(monkeypatch) -> None:
|
||||||
log_capture = _capture_codex_warnings(monkeypatch)
|
log_capture = _capture_codex_warnings(monkeypatch)
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
|
from nanobot.security.workspace_access import (
|
||||||
|
bind_workspace_scope,
|
||||||
|
build_workspace_scope,
|
||||||
|
reset_workspace_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||||
@@ -68,6 +72,21 @@ def test_exec_core_full_workspace_scope_blocks_loopback(tmp_path):
|
|||||||
assert "internal/private" in error
|
assert "internal/private" in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_explicit_local_service_access_allows_loopback(tmp_path):
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), allow_local_service_access=True)
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||||
|
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_explicit_local_service_access_still_blocks_metadata(tmp_path):
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), allow_local_service_access=True)
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
|
||||||
|
error = tool._guard_command("curl http://169.254.169.254/latest/meta-data/", str(tmp_path))
|
||||||
|
assert error is not None
|
||||||
|
assert "internal/private" in error
|
||||||
|
|
||||||
|
|
||||||
def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path):
|
def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path):
|
||||||
tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False)
|
tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False)
|
||||||
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
|
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
|
||||||
|
|||||||
@@ -104,6 +104,84 @@ def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
|||||||
assert "Exit code: 0" in result
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_detach_starts_background_process(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
ready_path = tmp_path / "ready.txt"
|
||||||
|
command = _python_command(
|
||||||
|
"import pathlib, time; "
|
||||||
|
"pathlib.Path('ready.txt').write_text('ok'); "
|
||||||
|
"time.sleep(0.6)"
|
||||||
|
)
|
||||||
|
result = await tool.execute(command=command, detach=True)
|
||||||
|
for _ in range(20):
|
||||||
|
if ready_path.exists():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "Detached process started." in result
|
||||||
|
assert "pid:" in result
|
||||||
|
assert "log:" in result
|
||||||
|
assert (tmp_path / "ready.txt").read_text() == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_detach_reports_immediate_exit(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
command = _python_command("print('boom'); raise SystemExit(7)")
|
||||||
|
return await tool.execute(command=command, detach=True)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "Detached process exited immediately with code 7" in result
|
||||||
|
assert "boom" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_long_output_summary_includes_failure_signals(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
command = _python_command(
|
||||||
|
"print('A' * 3000); "
|
||||||
|
"print('FAILED ../tests/test_outputs.py::test_artifact - AssertionError: missing output'); "
|
||||||
|
"print(\"FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'\"); "
|
||||||
|
"print('B' * 3000); "
|
||||||
|
"raise SystemExit(1)"
|
||||||
|
)
|
||||||
|
return await tool.execute(command=command, max_output_tokens=2500)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "[tool output truncated]" in result
|
||||||
|
assert "chars truncated" in result
|
||||||
|
assert "failed_tests:" in result
|
||||||
|
assert "../tests/test_outputs.py::test_artifact" in result
|
||||||
|
assert "missing_artifacts:" in result
|
||||||
|
assert "/app/out.txt" in result
|
||||||
|
assert "head:" in result
|
||||||
|
assert "tail:" in result
|
||||||
|
assert "[Verification Feedback]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_adds_verification_feedback_for_test_failures(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
command = _python_command(
|
||||||
|
"print('FAILED test_outputs.py::test_answer - AssertionError: wrong'); "
|
||||||
|
"print('AssertionError: wrong'); raise SystemExit(1)"
|
||||||
|
)
|
||||||
|
return await tool.execute(command=command)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "Exit code: 1" in result
|
||||||
|
assert "[Verification Feedback]" in result
|
||||||
|
assert "Do not call complete_goal" in result
|
||||||
|
assert "test_outputs.py::test_answer" in result
|
||||||
|
|
||||||
|
|
||||||
def test_exec_accepts_supported_shell_parameter(tmp_path):
|
def test_exec_accepts_supported_shell_parameter(tmp_path):
|
||||||
async def run() -> str:
|
async def run() -> str:
|
||||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
@@ -235,6 +313,35 @@ def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
|
|||||||
assert "Session terminated." in cleanup
|
assert "Session terminated." in cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_stdin_long_output_summary_includes_failure_signals(tmp_path):
|
||||||
|
async def run() -> str:
|
||||||
|
manager = ExecSessionManager()
|
||||||
|
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||||
|
command = _python_command(
|
||||||
|
"print('A' * 3000); "
|
||||||
|
"print('FAILED test_outputs.py::test_file - AssertionError: bad'); "
|
||||||
|
"print(\"FileNotFoundError: [Errno 2] No such file or directory: '/app/missing.txt'\"); "
|
||||||
|
"print('B' * 3000); "
|
||||||
|
"raise SystemExit(1)"
|
||||||
|
)
|
||||||
|
return await exec_tool.execute(
|
||||||
|
command=command,
|
||||||
|
yield_time_ms=1000,
|
||||||
|
max_output_tokens=2500,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert "[tool output truncated]" in result
|
||||||
|
assert "chars truncated" in result
|
||||||
|
assert "failed_tests:" in result
|
||||||
|
assert "test_outputs.py::test_file" in result
|
||||||
|
assert "missing_artifacts:" in result
|
||||||
|
assert "/app/missing.txt" in result
|
||||||
|
assert "Exit code: 1" in result
|
||||||
|
assert "[Verification Feedback]" in result
|
||||||
|
|
||||||
|
|
||||||
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
|
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
|
||||||
async def run() -> tuple[str, str]:
|
async def run() -> tuple[str, str]:
|
||||||
manager = ExecSessionManager()
|
manager = ExecSessionManager()
|
||||||
|
|||||||
@@ -660,10 +660,12 @@ async def test_exec_head_tail_truncation(tmp_path) -> None:
|
|||||||
else:
|
else:
|
||||||
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}"
|
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}"
|
||||||
result = await tool.execute(command=command)
|
result = await tool.execute(command=command)
|
||||||
|
assert "[tool output truncated]" in result
|
||||||
assert "chars truncated" in result
|
assert "chars truncated" in result
|
||||||
# Head portion should start with As
|
assert "head:" in result
|
||||||
assert result.startswith("A")
|
assert "tail:" in result
|
||||||
# Tail portion should end with the exit code which comes after Bs
|
assert "A" * 80 in result
|
||||||
|
assert "B" * 80 in result
|
||||||
assert "Exit code:" in result
|
assert "Exit code:" in result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user