M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
4bbcb09c8e
commit
154a0c3fa8
119 files changed
+1086
-912
No files matched your search
@@ -11,8 +11,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.bench.tasks import Task
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
CanonicalEvent,
|
||||
Fact,
|
||||
StoredEvent,
|
||||
@@ -20,9 +19,11 @@ from nisse_dev.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.catalog import ModelFacts
|
||||
from nisse_core.catalog import ModelFacts
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_dev.bench.tasks import Task
|
||||
from nisse_dev.config import Tuning
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.loop.agent import SYSTEM
|
||||
from nisse_dev.loop.driver import Driver, RunOutcome
|
||||
from nisse_dev.loop.tools import (
|
||||
@@ -31,7 +32,6 @@ from nisse_dev.loop.tools import (
|
||||
FINISH_MILESTONE,
|
||||
START_MILESTONE,
|
||||
)
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import EXECUTION_TOOLS, Workspace
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""The operator's kill switch, as the working loops see it (architecture L2).
|
||||
|
||||
`stop` has to reach work that is already running, from a thread that is not
|
||||
running it. So it is a flag, set by whoever hears the command and checked by
|
||||
the loops at each tool boundary. Setting it touches nothing durable: the
|
||||
record of a stop is written by the worker when the stopped work returns.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from nisse_dev.canonical import ToolCall, ToolResult
|
||||
from nisse_dev.state import Transcript
|
||||
|
||||
STOPPED_RESULT = "not run: the operator stopped the agent"
|
||||
|
||||
|
||||
class Cancel:
|
||||
def __init__(self) -> None:
|
||||
self._event = threading.Event()
|
||||
|
||||
def set(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._event.clear()
|
||||
|
||||
@property
|
||||
def requested(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
|
||||
def skip_calls(
|
||||
transcript: Transcript, calls: list[ToolCall], reason: str = STOPPED_RESULT
|
||||
) -> None:
|
||||
"""Record calls left unrun. Every tool call needs a result for the
|
||||
transcript to render again, and the result says what happened."""
|
||||
for call in calls:
|
||||
transcript.append(
|
||||
ToolResult(call_id=call.call_id, result=reason, success=False)
|
||||
)
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Canonical transcript schema (architecture L7).
|
||||
|
||||
The durable record is this event log, never a provider-shaped ``messages``
|
||||
array; provider request bodies are rendered from it at call time. Canonical
|
||||
tool-call IDs are minted locally — provider IDs are mapped at the boundary and
|
||||
never used as a join key.
|
||||
|
||||
Transcript appends are the conversation record itself, not state-machine
|
||||
mutations, so they are deliberately outside the L12 state-change taxonomy.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserMessage:
|
||||
text: str
|
||||
source: str # e.g. a Matrix event ID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantText:
|
||||
text: str
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCall:
|
||||
call_id: str # canonical, minted locally via mint_call_id()
|
||||
tool: str
|
||||
arguments: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
call_id: str
|
||||
result: str
|
||||
success: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanSubmitted:
|
||||
plan: str
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateOpened:
|
||||
gate: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DelegationStarted:
|
||||
brief: str
|
||||
role: str
|
||||
model: str # the alias asked for, e.g. "remote-architecture"
|
||||
caps: dict[str, Any] # turns / tokens / wall-clock limits
|
||||
# What the alias resolved to at task start. An alias is free to change
|
||||
# models between tasks, so eval history (L9) keyed on the alias alone
|
||||
# would average results across whatever it happened to point at.
|
||||
backing_model: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DelegationCompleted:
|
||||
handback: str
|
||||
artifacts: list[str]
|
||||
tokens: int
|
||||
duration_s: float
|
||||
exit_status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fact:
|
||||
"""Something the orchestrator knows that the models must too (L6, L7):
|
||||
a plan approved, a milestone finished, a PR opened. Written by the
|
||||
orchestrator; every role sees it, as a statement rather than a turn."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionVerdict:
|
||||
"""What the fast path made of a tool call before it ran (P4, L4): a
|
||||
record for the log, the operator and the classifier's evaluation set,
|
||||
never shown to a model."""
|
||||
|
||||
call_id: str
|
||||
tool: str
|
||||
effect: str # harmless | contained | unknown | dangerous | refused
|
||||
rule: str
|
||||
reason: str
|
||||
decision: str # ran | refused | asked | allowed | classified
|
||||
# Who decided: the fast path's rules, or the classifier (L4), with the
|
||||
# model it ran on and exactly what it was shown (the evaluation set, L9).
|
||||
source: str = "fast_path"
|
||||
model: str = ""
|
||||
input: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Compaction:
|
||||
summary: str
|
||||
model: str
|
||||
replaces: list[int] # [first_id, last_id] inclusive range replaced
|
||||
|
||||
|
||||
CanonicalEvent = (
|
||||
UserMessage
|
||||
| AssistantText
|
||||
| ToolCall
|
||||
| ToolResult
|
||||
| PlanSubmitted
|
||||
| GateOpened
|
||||
| DelegationStarted
|
||||
| DelegationCompleted
|
||||
| Fact
|
||||
| ActionVerdict
|
||||
| Compaction
|
||||
)
|
||||
|
||||
EVENT_TYPES: dict[str, type] = {
|
||||
"user_message": UserMessage,
|
||||
"assistant_text": AssistantText,
|
||||
"tool_call": ToolCall,
|
||||
"tool_result": ToolResult,
|
||||
"plan_submitted": PlanSubmitted,
|
||||
"gate_opened": GateOpened,
|
||||
"delegation_started": DelegationStarted,
|
||||
"delegation_completed": DelegationCompleted,
|
||||
"fact": Fact,
|
||||
"action_verdict": ActionVerdict,
|
||||
"compaction": Compaction,
|
||||
}
|
||||
|
||||
_TYPE_NAMES = {cls: name for name, cls in EVENT_TYPES.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredEvent:
|
||||
id: int
|
||||
ts: str
|
||||
event: CanonicalEvent
|
||||
# The work item it belongs to (None: agent-level, L6) and the role that
|
||||
# produced it (L7). Rendering uses both.
|
||||
work_item: str | None = None
|
||||
actor: str = ""
|
||||
|
||||
|
||||
def mint_call_id() -> str:
|
||||
return f"call-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def event_type(event: CanonicalEvent) -> str:
|
||||
return _TYPE_NAMES[type(event)]
|
||||
|
||||
|
||||
def to_payload(event: CanonicalEvent) -> dict[str, Any]:
|
||||
return asdict(event)
|
||||
|
||||
|
||||
def from_payload(type_name: str, payload: dict[str, Any]) -> CanonicalEvent:
|
||||
cls = EVENT_TYPES.get(type_name)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown canonical event type: {type_name!r}")
|
||||
known = {f.name for f in fields(cls)}
|
||||
unknown = payload.keys() - known
|
||||
if unknown:
|
||||
raise ValueError(f"unknown fields for {type_name}: {sorted(unknown)}")
|
||||
return cls(**payload)
|
||||
@@ -1,227 +0,0 @@
|
||||
"""Runtime model discovery (architecture L9).
|
||||
|
||||
LiteLLM is the VM's single LLM destination, which makes it the authority on
|
||||
what any given model *is*. Asking it at startup keeps vendor knowledge in one
|
||||
place instead of duplicating it as name patterns in agent code -- and that
|
||||
stops being a tidiness argument the moment a role alias no longer names its
|
||||
vendor. An alias like ``remote-architecture`` is deliberately free to point at
|
||||
Anthropic today and something else tomorrow; nothing in the name can tell us
|
||||
which request shape the model behind it expects.
|
||||
|
||||
Discovery is per key, not global: ``/model/info`` returns only the models the
|
||||
presented token is allowed to use. So the same check that catches a typo'd
|
||||
alias also catches a role pointed at a model this key cannot reach.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
|
||||
# LiteLLM's provider name for models we have a vendor-native adapter for.
|
||||
ANTHROPIC_PROVIDER = "anthropic"
|
||||
|
||||
|
||||
class CatalogUnavailable(LLMError):
|
||||
"""The catalog could not be fetched: the proxy is down, unreachable, or
|
||||
refused the token. An outage, not a mistake in the configuration."""
|
||||
|
||||
|
||||
class UnknownModel(LLMError):
|
||||
"""A model was named that this key's catalog does not contain: a typo, a
|
||||
stale alias, or a key without access. Always a misconfiguration."""
|
||||
|
||||
|
||||
class UnknownLimits(LLMError):
|
||||
"""A model in use has no declared context window. LiteLLM knows the
|
||||
limits of commercial models; for a local one they are whatever its server
|
||||
allocates, and have to be declared in LiteLLM's ``model_info``. Guessing
|
||||
is not safe: a server that silently truncates a prompt (Ollama does)
|
||||
drops its start, which is where the system prompt and the plan are."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelFacts:
|
||||
alias: str # what we ask for, e.g. "remote-architecture"
|
||||
backing: str # what LiteLLM calls, e.g. "anthropic/claude-opus-5"
|
||||
provider: str # "anthropic", "openai", "ollama", ...
|
||||
supports_tools: bool | None # None when LiteLLM does not say
|
||||
# The whole context window, prompt plus response, as LiteLLM declares it
|
||||
# (model_info.max_input_tokens); None when it does not say.
|
||||
max_input_tokens: int | None = None
|
||||
# The most one response may use (model_info.max_output_tokens).
|
||||
max_output_tokens: int | None = None
|
||||
# Whether its server enforces tool_choice (model_info.supports_tool_choice).
|
||||
# Only a declared True counts: a model that says nothing is not asked.
|
||||
supports_tool_choice: bool | None = None
|
||||
|
||||
@property
|
||||
def native_anthropic(self) -> bool:
|
||||
return self.provider == ANTHROPIC_PROVIDER
|
||||
|
||||
def answered(self, reported: str | None) -> bool:
|
||||
"""Whether a response reporting this model name came from this model.
|
||||
|
||||
A proxy fallback answers under another model's name. LiteLLM reports
|
||||
the alias; a provider may report its own name for the backing model,
|
||||
sometimes with a date suffix ("claude-haiku-4-5-20251001").
|
||||
"""
|
||||
if not reported:
|
||||
return True # nothing reported, nothing contradicted
|
||||
own_name = self.backing.partition("/")[2] or self.backing
|
||||
return reported in (self.alias, self.backing) or reported.startswith(own_name)
|
||||
|
||||
|
||||
class ModelCatalog:
|
||||
"""What one LiteLLM key can see, as of the moment it was asked."""
|
||||
|
||||
def __init__(self, facts: Mapping[str, ModelFacts]) -> None:
|
||||
self._facts = dict(facts)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, Any]) -> "ModelCatalog":
|
||||
facts: dict[str, ModelFacts] = {}
|
||||
for entry in payload.get("data") or []:
|
||||
alias = entry.get("model_name")
|
||||
if not alias:
|
||||
continue
|
||||
params = entry.get("litellm_params") or {}
|
||||
info = entry.get("model_info") or {}
|
||||
backing = str(params.get("model") or "")
|
||||
# litellm_provider is the direct answer; the backing model's
|
||||
# "vendor/model" prefix says the same thing and is present even
|
||||
# when the provider field is not.
|
||||
provider = info.get("litellm_provider") or backing.partition("/")[0]
|
||||
facts[alias] = ModelFacts(
|
||||
alias=alias,
|
||||
backing=backing,
|
||||
provider=str(provider),
|
||||
supports_tools=info.get("supports_function_calling"),
|
||||
max_input_tokens=_positive_int(info.get("max_input_tokens")),
|
||||
max_output_tokens=_positive_int(info.get("max_output_tokens")),
|
||||
supports_tool_choice=info.get("supports_tool_choice"),
|
||||
)
|
||||
return cls(facts)
|
||||
|
||||
@classmethod
|
||||
def discover(
|
||||
cls,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_s: float = 30.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> "ModelCatalog":
|
||||
try:
|
||||
with httpx.Client(
|
||||
base_url=base_url.rstrip("/"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
) as http:
|
||||
response = http.get("/model/info")
|
||||
except httpx.HTTPError as exc:
|
||||
raise CatalogUnavailable(
|
||||
f"could not reach the LiteLLM proxy at {base_url} to discover "
|
||||
f"models: {exc}"
|
||||
) from exc
|
||||
if response.status_code != 200:
|
||||
raise CatalogUnavailable(
|
||||
f"the LiteLLM proxy refused a model listing with "
|
||||
f"{response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
return cls.from_payload(response.json())
|
||||
|
||||
def aliases(self) -> list[str]:
|
||||
return sorted(self._facts)
|
||||
|
||||
def facts(self, alias: str) -> ModelFacts:
|
||||
try:
|
||||
return self._facts[alias]
|
||||
except KeyError:
|
||||
raise UnknownModel(
|
||||
f"{alias!r} is not a model this LiteLLM key can see; "
|
||||
f"available: {', '.join(self.aliases()) or '(none)'}"
|
||||
) from None
|
||||
|
||||
def validate(self, roles: Mapping[str, str]) -> None:
|
||||
"""Check a whole role mapping at once, before anything uses it.
|
||||
|
||||
Reported together rather than one per boot: an operator fixing a
|
||||
config wants the whole list, not to rediscover the next typo on the
|
||||
next start (the same reason ``AgentConfig`` reports every missing
|
||||
setting at once).
|
||||
"""
|
||||
unknown = {
|
||||
role: alias for role, alias in roles.items() if alias not in self._facts
|
||||
}
|
||||
if unknown:
|
||||
named = ", ".join(
|
||||
f"{role}={alias!r}" for role, alias in sorted(unknown.items())
|
||||
)
|
||||
raise UnknownModel(
|
||||
f"role(s) point at models this LiteLLM key cannot see: {named}; "
|
||||
f"available: {', '.join(self.aliases()) or '(none)'}"
|
||||
)
|
||||
|
||||
def require_limits(self, roles: Mapping[str, str]) -> None:
|
||||
"""Refuse a role mapping that uses a model with no declared context
|
||||
window, naming every such model at once."""
|
||||
missing = {
|
||||
role: alias
|
||||
for role, alias in roles.items()
|
||||
if self.facts(alias).max_input_tokens is None
|
||||
}
|
||||
if missing:
|
||||
named = ", ".join(
|
||||
f"{role}={alias!r}" for role, alias in sorted(missing.items())
|
||||
)
|
||||
raise UnknownLimits(
|
||||
f"no context window is declared for: {named}. Set "
|
||||
"model_info.max_input_tokens (and max_output_tokens) for these "
|
||||
"in LiteLLM; docs/Tuning-Guide.md says how to find the values"
|
||||
)
|
||||
|
||||
def pinned(self, roles: Mapping[str, str]) -> dict[str, dict[str, Any]]:
|
||||
"""The resolved mapping to record at task start (L9).
|
||||
|
||||
Recording only the alias would let an alias that silently changes
|
||||
models muddle the eval history behind it: "remote-architecture: 80%
|
||||
good" averaged over however many models it pointed at is not a fact
|
||||
about anything.
|
||||
"""
|
||||
return {
|
||||
role: {
|
||||
"alias": alias,
|
||||
"backing": self.facts(alias).backing,
|
||||
"provider": self.facts(alias).provider,
|
||||
}
|
||||
for role, alias in roles.items()
|
||||
}
|
||||
|
||||
|
||||
def _positive_int(value: Any) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
return int(value) if value > 0 else None
|
||||
|
||||
|
||||
def select_client(
|
||||
model: str,
|
||||
normalized: LLMClient,
|
||||
passthrough: LLMClient,
|
||||
catalog: ModelCatalog,
|
||||
) -> LLMClient:
|
||||
"""Choose the route from what the model *is*, not from what it is called.
|
||||
|
||||
L9 spells out the rule: use the vendor-native adapter where one exists,
|
||||
and otherwise provisionally accept LiteLLM's normalization for that
|
||||
vendor -- validating it against the L9 eval set before trusting it with
|
||||
delegations. There is deliberately no name-based fallback here: a second
|
||||
heuristic could disagree with the proxy, and an unknown model raises
|
||||
rather than quietly taking the normalized route.
|
||||
"""
|
||||
return passthrough if catalog.facts(model).native_anthropic else normalized
|
||||
@@ -23,9 +23,9 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap
|
||||
from nisse_dev.matrix import MatrixConfig
|
||||
from nisse_dev.vault import ACCESS, DEFAULT_IGNORE
|
||||
from nisse_core.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap
|
||||
from nisse_core.matrix import MatrixConfig
|
||||
from nisse_core.vault import ACCESS, DEFAULT_IGNORE
|
||||
|
||||
DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
|
||||
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
"""The context budget (architecture L6: token budgeting is a render-time concern).
|
||||
|
||||
Every model call re-sends a transcript that only grows, and a model server
|
||||
does not always refuse one that no longer fits: Ollama silently drops the
|
||||
start of the prompt, which is where the system prompt and the plan are. So a
|
||||
request is made to fit the model's declared window *before* it is sent, and
|
||||
one that cannot be made to fit is never sent.
|
||||
|
||||
The log itself is never changed. Fitting works on the events about to be
|
||||
rendered, in three steps, stopping at the first that fits:
|
||||
|
||||
1. Tool results older than the most recent turns are cut to their first and
|
||||
last lines, with a note saying how much was left out.
|
||||
2. The oldest events are dropped, keeping tool calls with their results, and
|
||||
a note at the start says how many.
|
||||
3. If even the recent turns and the system prompt do not fit, the call
|
||||
fails with ContextOverflow. The caller treats it like any failed call:
|
||||
the executor stops and asks the operator.
|
||||
|
||||
Token counts are estimated from characters, conservatively, and the estimate
|
||||
is corrected from the input-token counts the server reports back. The
|
||||
correction only ever makes the estimate larger: underestimating is the
|
||||
dangerous direction.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
DelegationStarted,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError
|
||||
|
||||
# English and code run at about four characters a token. Three is deliberately
|
||||
# pessimistic, before any correction from what the server reports.
|
||||
CHARS_PER_TOKEN = 3.0
|
||||
# Role markers and formatting each rendered message costs beyond its text.
|
||||
MESSAGE_OVERHEAD_TOKENS = 8
|
||||
# How much of an older tool result survives step 1.
|
||||
ELIDED_RESULT_CHARS = 600
|
||||
|
||||
# Events the model produced. A turn starts where these start.
|
||||
_MODEL_SIDE = (AssistantText, ToolCall, PlanSubmitted, DelegationStarted)
|
||||
|
||||
|
||||
class ContextOverflow(LLMError):
|
||||
"""The request cannot be made to fit the model's context window."""
|
||||
|
||||
|
||||
def head_and_tail(text: str, limit: int) -> str:
|
||||
"""Keep the start and, mostly, the end of a long text.
|
||||
|
||||
The end matters more: test runners and compilers put their summary of
|
||||
what failed last. The start keeps the exit code and the first lines.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
head = limit // 4
|
||||
tail = limit - head
|
||||
dropped = len(text) - limit
|
||||
return f"{text[:head]}\n[... {dropped} characters omitted ...]\n{text[-tail:]}"
|
||||
|
||||
|
||||
def _event_chars(event: CanonicalEvent) -> int:
|
||||
match event:
|
||||
case ToolCall(tool=tool, arguments=arguments):
|
||||
return len(tool) + len(json.dumps(arguments))
|
||||
case ToolResult(result=result):
|
||||
return len(result)
|
||||
case _:
|
||||
text = getattr(event, "text", None) or getattr(event, "plan", None)
|
||||
if text is None:
|
||||
text = json.dumps(getattr(event, "__dict__", {}), default=str)
|
||||
return len(str(text))
|
||||
|
||||
|
||||
def estimate_tokens(
|
||||
events: Sequence[StoredEvent],
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
factor: float = 1.0,
|
||||
) -> int:
|
||||
chars = sum(_event_chars(e.event) for e in events)
|
||||
chars += len(system or "") + len(json.dumps(tools or []))
|
||||
raw = chars / CHARS_PER_TOKEN + MESSAGE_OVERHEAD_TOKENS * (len(events) + 1)
|
||||
return int(raw * factor) + 1
|
||||
|
||||
|
||||
def _turn_starts(events: Sequence[StoredEvent]) -> list[int]:
|
||||
starts = []
|
||||
previous_model_side = False
|
||||
for index, stored in enumerate(events):
|
||||
model_side = isinstance(stored.event, _MODEL_SIDE)
|
||||
if model_side and not previous_model_side:
|
||||
starts.append(index)
|
||||
previous_model_side = model_side
|
||||
return starts
|
||||
|
||||
|
||||
def _consistent(events: list[StoredEvent]) -> list[StoredEvent]:
|
||||
"""Drop results whose call is gone: a provider rejects a tool result
|
||||
with nothing to answer."""
|
||||
calls = {e.event.call_id for e in events if isinstance(e.event, ToolCall)}
|
||||
return [
|
||||
e
|
||||
for e in events
|
||||
if not isinstance(e.event, ToolResult) or e.event.call_id in calls
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Budget:
|
||||
window: int # prompt plus response, in tokens
|
||||
max_output: int # the ceiling sent with the request
|
||||
keep_recent_turns: int
|
||||
safety_margin: float # fraction of the window held back
|
||||
|
||||
@property
|
||||
def for_prompt(self) -> int:
|
||||
return int(self.window * (1 - self.safety_margin)) - self.max_output
|
||||
|
||||
|
||||
def fit(
|
||||
events: Sequence[StoredEvent],
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
budget: Budget,
|
||||
factor: float = 1.0,
|
||||
) -> list[StoredEvent]:
|
||||
"""The events to render: all of them if they fit, otherwise the result of
|
||||
the three steps in the module docstring."""
|
||||
limit = budget.for_prompt
|
||||
|
||||
def fits(candidate: Sequence[StoredEvent]) -> bool:
|
||||
return estimate_tokens(candidate, system, tools, factor) <= limit
|
||||
|
||||
kept = list(events)
|
||||
if fits(kept):
|
||||
return kept
|
||||
|
||||
starts = _turn_starts(kept)
|
||||
recent_from = (
|
||||
starts[-budget.keep_recent_turns]
|
||||
if len(starts) >= budget.keep_recent_turns
|
||||
else 0
|
||||
)
|
||||
|
||||
# Step 1: cut older tool results down.
|
||||
kept = [
|
||||
replace(
|
||||
stored,
|
||||
event=replace(
|
||||
stored.event,
|
||||
result=head_and_tail(stored.event.result, ELIDED_RESULT_CHARS),
|
||||
),
|
||||
)
|
||||
if index < recent_from and isinstance(stored.event, ToolResult)
|
||||
else stored
|
||||
for index, stored in enumerate(kept)
|
||||
]
|
||||
if fits(kept):
|
||||
return kept
|
||||
|
||||
# Step 2: drop the oldest events, one turn at a time, never the recent
|
||||
# ones. The note keeps the transcript starting with a user message, which
|
||||
# some chat templates require.
|
||||
cuts = sorted({s for s in starts if 0 < s < recent_from} | {recent_from} - {0})
|
||||
for cut in cuts:
|
||||
remaining = _consistent(kept[cut:])
|
||||
note = StoredEvent(
|
||||
id=0,
|
||||
ts="",
|
||||
event=UserMessage(
|
||||
text=(
|
||||
f"[{cut} earlier events omitted to fit the context window; "
|
||||
"the log still has them]"
|
||||
),
|
||||
source="context-budget",
|
||||
),
|
||||
)
|
||||
candidate = [note, *remaining]
|
||||
if fits(candidate):
|
||||
return candidate
|
||||
|
||||
# Step 3: nothing more can go.
|
||||
needed = estimate_tokens(kept[recent_from:], system, tools, factor)
|
||||
raise ContextOverflow(
|
||||
f"the system prompt and the last {budget.keep_recent_turns} turns need "
|
||||
f"about {needed} tokens, but only {limit} fit a {budget.window}-token "
|
||||
"window after the output ceiling and safety margin"
|
||||
)
|
||||
|
||||
|
||||
class BudgetedClient:
|
||||
"""An LLM client that fits every request to the model's window.
|
||||
|
||||
Wraps a real client (either route) and takes each model's limits from
|
||||
``limits_for``, which the agent fills from LiteLLM's model catalog.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: Any,
|
||||
limits_for: Callable[[str], tuple[int, int | None]],
|
||||
*,
|
||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
keep_recent_turns: int = 6,
|
||||
safety_margin: float = 0.1,
|
||||
enforces_tool_choice: Callable[[str], bool] = lambda model: False,
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self._limits_for = limits_for
|
||||
# Whether a model's server holds it to the offered tools when asked
|
||||
# (LiteLLM's supports_tool_choice). Asking one that doesn't can be
|
||||
# worse than not asking: qwen3-14b can't stop, and rambles until the
|
||||
# output ceiling.
|
||||
self._enforces_tool_choice = enforces_tool_choice
|
||||
self._max_output_tokens = max_output_tokens
|
||||
self._keep_recent_turns = keep_recent_turns
|
||||
self._safety_margin = safety_margin
|
||||
# Per model: how much the character estimate undercounted, as learned
|
||||
# from reported usage. Starts at 1 and only grows.
|
||||
self._factors: dict[str, float] = {}
|
||||
self.trimmed_requests = 0
|
||||
|
||||
@property
|
||||
def last_usage(self) -> dict[str, int]:
|
||||
return getattr(self._inner, "last_usage", {}) or {}
|
||||
|
||||
@property
|
||||
def last_model(self) -> str | None:
|
||||
return getattr(self._inner, "last_model", None)
|
||||
|
||||
@property
|
||||
def last_cost(self) -> float:
|
||||
return float(getattr(self._inner, "last_cost", 0.0) or 0.0)
|
||||
|
||||
@property
|
||||
def last_rate_limit_wait_s(self) -> float:
|
||||
return float(getattr(self._inner, "last_rate_limit_wait_s", 0.0) or 0.0)
|
||||
|
||||
def budget_for(self, model: str) -> Budget:
|
||||
window, model_max_output = self._limits_for(model)
|
||||
ceiling = self._max_output_tokens
|
||||
if model_max_output is not None:
|
||||
ceiling = min(ceiling, model_max_output)
|
||||
return Budget(
|
||||
window=window,
|
||||
max_output=ceiling,
|
||||
keep_recent_turns=self._keep_recent_turns,
|
||||
safety_margin=self._safety_margin,
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> list[CanonicalEvent]:
|
||||
budget = self.budget_for(model)
|
||||
factor = self._factors.get(model, 1.0)
|
||||
fitted = fit(events, system, tools, budget, factor)
|
||||
# Counted so the benchmark can report how often a model's window was
|
||||
# the constraint: any request that had to be cut down or shortened.
|
||||
if fitted != list(events):
|
||||
self.trimmed_requests += 1
|
||||
produced = self._inner.complete(
|
||||
fitted,
|
||||
tools,
|
||||
model,
|
||||
system=system,
|
||||
max_tokens=budget.max_output,
|
||||
# Every caller that offers tools needs one called: the executor
|
||||
# finishes with finish, the planner with submit_plan or
|
||||
# ask_human, a delegation with hand_back.
|
||||
require_tool_call=bool(tools) and self._enforces_tool_choice(model),
|
||||
)
|
||||
reported = int(self.last_usage.get("input", 0))
|
||||
if reported > 0:
|
||||
estimated = estimate_tokens(fitted, system, tools)
|
||||
observed = reported / max(estimated, 1)
|
||||
self._factors[model] = max(factor, observed)
|
||||
return produced
|
||||
@@ -28,8 +28,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from nisse_dev.cancel import Cancel, skip_calls
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.cancel import Cancel, skip_calls
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
@@ -39,10 +39,11 @@ from nisse_dev.canonical import (
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_core.llm import LLMClient, LLMError, UpstreamUnavailable
|
||||
from nisse_core.reconcile import Reconciler
|
||||
from nisse_core.state import Intent, Transcript
|
||||
from nisse_dev.git import GitError, GitRepo
|
||||
from nisse_dev.llm import LLMClient, LLMError, UpstreamUnavailable
|
||||
from nisse_dev.reconcile import Reconciler
|
||||
from nisse_dev.state import Intent, StateStore, Transcript
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import EXECUTION_TOOLS, MUTATING_TOOLS, Workspace
|
||||
|
||||
DELEGATION_INTENT = "delegation"
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"""A readable dump of a state store, for bring-up runs and debugging.
|
||||
|
||||
python -m nisse_dev.dump STATE_DB [--full]
|
||||
|
||||
Opens the store read-only (SQLite `mode=ro`), so it is safe to run against a
|
||||
live agent's database: it never migrates, writes or locks it. Prints the work
|
||||
items and which is active, pending intents, and the canonical log, one event
|
||||
per line with its work item and actor; long texts are shortened unless
|
||||
`--full` is given.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SHORT = 160 # characters of each text shown without --full
|
||||
|
||||
|
||||
def _connect(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _clip(text: str, full: bool) -> str:
|
||||
text = text.replace("\n", " ⏎ ") if not full else text
|
||||
if full or len(text) <= SHORT:
|
||||
return text
|
||||
return text[:SHORT] + f" … [{len(text) - SHORT} more]"
|
||||
|
||||
|
||||
def _event_line(kind: str, payload: dict[str, Any], full: bool) -> str:
|
||||
match kind:
|
||||
case "user_message":
|
||||
return f"USER [{payload.get('source', '')}]: {_clip(payload['text'], full)}"
|
||||
case "assistant_text":
|
||||
return f"ASSISTANT ({payload.get('model', '')}): {_clip(payload['text'], full)}"
|
||||
case "tool_call":
|
||||
args = json.dumps(payload.get("arguments", {}), ensure_ascii=False)
|
||||
return f"CALL {payload['tool']} {_clip(args, full)}"
|
||||
case "tool_result":
|
||||
mark = "ok" if payload.get("success") else "FAILED"
|
||||
return f" result ({mark}): {_clip(str(payload.get('result', '')), full)}"
|
||||
case "plan_submitted":
|
||||
plan = payload["plan"]
|
||||
return f"PLAN ({payload.get('model', '')}, {len(plan)} chars): {_clip(plan, full)}"
|
||||
case "fact":
|
||||
return f"FACT: {_clip(payload['text'], full)}"
|
||||
case "action_verdict":
|
||||
return (
|
||||
f" VERDICT {payload.get('effect', '')} ({payload.get('rule', '')}), "
|
||||
f"{payload.get('decision', '')}: {payload.get('reason', '')}"
|
||||
)
|
||||
case "gate_opened":
|
||||
return f"GATE {payload.get('gate', '')}: {payload.get('reason', '')}"
|
||||
case _:
|
||||
return f"{kind.upper()}: {_clip(json.dumps(payload, ensure_ascii=False), full)}"
|
||||
|
||||
|
||||
def render(path: Path, *, full: bool = False) -> str:
|
||||
conn = _connect(path)
|
||||
lines: list[str] = []
|
||||
agent = conn.execute("SELECT * FROM agent WHERE id = 1").fetchone()
|
||||
active = agent["active_item"] if agent else None
|
||||
lines.append(f"active work item: {active or '(none: idle)'}")
|
||||
for item in conn.execute("SELECT * FROM work_items ORDER BY opened_at"):
|
||||
loop = json.loads(item["data"]).get("loop") or {}
|
||||
status = item["status"] + (f" ({item['outcome']})" if item["outcome"] else "")
|
||||
waiting = (
|
||||
f" waits for: {loop['wait']}" if item["position"] == "WAITING" else ""
|
||||
)
|
||||
lines.append(
|
||||
f" {item['id']} {status} position: {item['position']} "
|
||||
f"scope: {item['scope']} rev-{loop.get('rev', 0)}{waiting}"
|
||||
)
|
||||
pending = conn.execute(
|
||||
"SELECT id, kind, payload, work_item FROM intent_log WHERE status = 'pending'"
|
||||
).fetchall()
|
||||
for row in pending:
|
||||
lines.append(
|
||||
f"pending intent #{row['id']} {row['kind']} "
|
||||
f"[{row['work_item'] or 'agent'}]: {row['payload']}"
|
||||
)
|
||||
lines.append("")
|
||||
for row in conn.execute("SELECT * FROM canonical_events ORDER BY id"):
|
||||
payload = json.loads(row["payload"])
|
||||
time = row["ts"][11:19]
|
||||
where = f"[{row['work_item'] or 'agent'}/{row['actor']}]"
|
||||
lines.append(
|
||||
f"#{row['id']} {time} {where} {_event_line(row['type'], payload, full)}"
|
||||
)
|
||||
conn.close()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("state_db", type=Path)
|
||||
parser.add_argument("--full", action="store_true", help="don't shorten texts")
|
||||
args = parser.parse_args(argv)
|
||||
if not args.state_db.exists():
|
||||
print(f"no state store at {args.state_db}", file=sys.stderr)
|
||||
return 1
|
||||
print(render(args.state_db, full=args.full))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Minimal .env loader (architecture L12: secrets from env/.env, nothing hardcoded).
|
||||
|
||||
Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing
|
||||
environment variables never overridden (real env wins over the file).
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import MutableMapping
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_env_file(path: Path, env: MutableMapping[str, str] = os.environ) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if key and key not in env:
|
||||
env[key] = value.strip()
|
||||
@@ -15,7 +15,7 @@ from collections.abc import Iterable
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
ActionVerdict,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Running commands on a tool VM over SSH (architecture L13).
|
||||
|
||||
``SSHHost.execute`` is the only way the agent runs anything: there is no
|
||||
local execution path, so the host the agent lives on never runs a command a
|
||||
model chose. The harness binds the host; a model never names one.
|
||||
|
||||
How a command travels. The remote side runs ``bash -s`` and reads a small
|
||||
script from SSH's stdin: the secrets handed to this one call as ``export``
|
||||
lines, a ``cd``, then the command under ``timeout``. Nothing secret is ever on
|
||||
a command line (``ps`` and ``/proc/*/cmdline`` would show it); the values exist
|
||||
only in that shell and the command's environment. The command's own stdin is
|
||||
``/dev/null``, or a here-document carrying ``stdin``, so it can never read the
|
||||
rest of the script.
|
||||
|
||||
Every value handed out is redacted from what comes back (L12): a test that
|
||||
prints its environment must not put a key into the transcript.
|
||||
|
||||
The connection is reused across calls (OpenSSH's ControlMaster), so a tool
|
||||
call costs one round trip, not a handshake.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets as _random
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# The remote command's exit code is passed back as is, except that 255 is
|
||||
# what ssh itself uses for "could not connect"; a command exiting 255 is
|
||||
# reported as 254 so the two can't be confused.
|
||||
SSH_FAILED = 255
|
||||
_TIMED_OUT = 124 # coreutils `timeout`
|
||||
_KILL_GRACE_S = 5
|
||||
# The local backstop, beyond the remote timeout, for a connection that hangs.
|
||||
_LOCAL_SLACK_S = 30
|
||||
|
||||
REDACTED = "[redacted]"
|
||||
|
||||
|
||||
class HostUnavailable(RuntimeError):
|
||||
"""The host could not be reached: not something the command did."""
|
||||
|
||||
|
||||
class HostKeyChanged(HostUnavailable):
|
||||
"""The host answered with a different key than the one pinned: rebuilt,
|
||||
or not the host it claims to be. Only the operator may trust the new
|
||||
key (M4.1)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_code == 0 and not self.timed_out
|
||||
|
||||
|
||||
def redact(text: str, values: Iterable[str]) -> str:
|
||||
"""Mask every non-trivial secret value in ``text``. Longest first, so a
|
||||
value containing another is masked whole."""
|
||||
for value in sorted((v for v in values if len(v) >= 4), key=len, reverse=True):
|
||||
text = text.replace(value, REDACTED)
|
||||
return text
|
||||
|
||||
|
||||
def _heredoc(body: str) -> tuple[str, str]:
|
||||
"""A here-document redirection whose delimiter can't occur in ``body``."""
|
||||
while True:
|
||||
tag = f"NISSE_EOF_{_random.token_hex(8)}"
|
||||
if tag not in body:
|
||||
return f"<<'{tag}'", f"\n{body}\n{tag}"
|
||||
|
||||
|
||||
def _script(
|
||||
command: str,
|
||||
cwd: str | None,
|
||||
secrets: Mapping[str, str],
|
||||
stdin: str | None,
|
||||
timeout_s: int,
|
||||
) -> str:
|
||||
lines = [f"export {name}={shlex.quote(value)}" for name, value in secrets.items()]
|
||||
if cwd is not None:
|
||||
lines.append(f"cd -- {shlex.quote(cwd)} || exit 125")
|
||||
redirect, body = ("< /dev/null", "") if stdin is None else _heredoc(stdin)
|
||||
# `timeout` runs the command in its own process group and signals the
|
||||
# whole group, so a backgrounded grandchild doesn't outlive it.
|
||||
lines.append(
|
||||
f"timeout --kill-after={_KILL_GRACE_S} {timeout_s} "
|
||||
f"bash -c {shlex.quote(command)} {redirect}{body}"
|
||||
)
|
||||
lines.append("rc=$?; [ $rc -eq 255 ] && rc=254; exit $rc")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class SSHHost:
|
||||
"""One tool VM, reached as ``user@address`` with a private key."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: str,
|
||||
*,
|
||||
key: Path,
|
||||
state_dir: Path,
|
||||
user: str = "root",
|
||||
port: int = 22,
|
||||
) -> None:
|
||||
self.address = address
|
||||
self.user = user
|
||||
self.port = port
|
||||
self._key = Path(key)
|
||||
state_dir = Path(state_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._known_hosts = state_dir / "known_hosts"
|
||||
# A Unix socket's path is limited to about 100 bytes, so the shared
|
||||
# connection's socket lives in a short private directory rather than
|
||||
# beside the state, named by %C, ssh's hash of the connection.
|
||||
sockets = Path(tempfile.gettempdir()) / f"nisse-ssh-{os.getuid()}"
|
||||
sockets.mkdir(mode=0o700, exist_ok=True)
|
||||
self._control = sockets / "%C"
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.user}@{self.address}"
|
||||
|
||||
def _ssh(self) -> list[str]:
|
||||
return [
|
||||
"ssh",
|
||||
"-i",
|
||||
str(self._key),
|
||||
"-p",
|
||||
str(self.port),
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
f"UserKnownHostsFile={self._known_hosts}",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"ControlMaster=auto",
|
||||
"-o",
|
||||
f"ControlPath={self._control}",
|
||||
"-o",
|
||||
"ControlPersist=120",
|
||||
"-o",
|
||||
"ConnectTimeout=15",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
f"{self.user}@{self.address}",
|
||||
"bash -s",
|
||||
]
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
secrets: Mapping[str, str] | None = None,
|
||||
stdin: str | None = None,
|
||||
timeout_s: int = 120,
|
||||
) -> Result:
|
||||
"""Run ``command`` with bash in ``cwd``. ``secrets`` become environment
|
||||
variables of this one command and are redacted from its output.
|
||||
Raises HostUnavailable when the host can't be reached."""
|
||||
handed_out = dict(secrets or {})
|
||||
script = _script(command, cwd, handed_out, stdin, timeout_s)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
self._ssh(),
|
||||
input=script.encode(),
|
||||
capture_output=True,
|
||||
timeout=timeout_s + _KILL_GRACE_S + _LOCAL_SLACK_S,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HostUnavailable(
|
||||
f"{self.describe()} stopped responding during a command"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise HostUnavailable(f"could not run ssh: {exc}") from exc
|
||||
stdout = completed.stdout.decode(errors="replace")
|
||||
stderr = completed.stderr.decode(errors="replace")
|
||||
if completed.returncode == SSH_FAILED and (
|
||||
"IDENTIFICATION HAS CHANGED" in stderr
|
||||
or "Host key verification failed" in stderr
|
||||
):
|
||||
raise HostKeyChanged(
|
||||
f"{self.describe()}'s host key changed since it was pinned"
|
||||
)
|
||||
if completed.returncode == SSH_FAILED:
|
||||
raise HostUnavailable(
|
||||
f"could not reach {self.describe()}: "
|
||||
+ redact(stderr.strip() or "ssh failed", handed_out.values())
|
||||
)
|
||||
values = handed_out.values()
|
||||
return Result(
|
||||
exit_code=completed.returncode,
|
||||
stdout=redact(stdout, values),
|
||||
stderr=redact(stderr, values),
|
||||
timed_out=completed.returncode in (_TIMED_OUT, 137),
|
||||
)
|
||||
|
||||
def forget_host_key(self) -> None:
|
||||
"""Drop the pinned key, so the next connection pins the host's new
|
||||
one. Only on the operator's word (M4.1)."""
|
||||
self.close()
|
||||
name = self.address if self.port == 22 else f"[{self.address}]:{self.port}"
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-R", name, "-f", str(self._known_hosts)],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""End the shared connection, if one is open."""
|
||||
args = self._ssh()[:-2] # without the destination and the command
|
||||
subprocess.run(
|
||||
[*args, "-O", "exit", f"{self.user}@{self.address}"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
@@ -26,14 +26,14 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
StoredEvent,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.llm import LLMClient, LLMError, RoleMap
|
||||
from nisse_dev.state import Transcript
|
||||
from nisse_core.llm import LLMClient, LLMError, RoleMap
|
||||
from nisse_core.state import Transcript
|
||||
|
||||
EXPERT_ROLE = "expert"
|
||||
DEFAULT_BUDGET = 3
|
||||
|
||||
@@ -21,7 +21,7 @@ import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.execution import HostUnavailable, SSHHost
|
||||
from nisse_core.execution import HostUnavailable, SSHHost
|
||||
|
||||
TIMEOUT_S = 120
|
||||
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
"""Gitea client (architecture D4, D5).
|
||||
|
||||
Outbound: open PRs and post comments through a dedicated service account with
|
||||
a scoped token. Inbound is deliberately *polled* rather than webhook-driven,
|
||||
so the agent VM stays outbound-only (D7), and intake is structured JSON —
|
||||
never routed through a chat bridge.
|
||||
|
||||
``find_pull_request`` is the external-truth query that makes opening a PR
|
||||
idempotent across a crash: boot reconciliation (L8) asks Gitea whether the
|
||||
effect already happened rather than repeating it.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.reconcile import Reconciler
|
||||
from nisse_dev.state import Intent
|
||||
|
||||
PR_PAGE_SIZE = 50
|
||||
MAX_PR_PAGES = 40 # 2000 PRs; beyond that, say so rather than guess
|
||||
|
||||
|
||||
class GiteaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PullRequest:
|
||||
number: int
|
||||
state: str
|
||||
head: str
|
||||
url: str
|
||||
merged: bool = False
|
||||
head_sha: str = "" # the commit the PR is at now
|
||||
mergeable: bool | None = None # None: Gitea didn't say
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Approval:
|
||||
"""An approving review, and the commit it approved (L3: approvals bind
|
||||
to exactly what was approved)."""
|
||||
|
||||
author: str
|
||||
commit: str
|
||||
stale: bool = False # Gitea's mark: commits were pushed since
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewNote:
|
||||
"""Something a reviewer said in a review: its body, or a comment on a
|
||||
line (#69). ``key`` is unique across both kinds."""
|
||||
|
||||
key: str
|
||||
author: str
|
||||
body: str
|
||||
where: str # "path:line" for a line comment, "" for a review body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Comment:
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Issue:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
author: str
|
||||
labels: tuple[str, ...]
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_s: float = 30.0,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._http = httpx.Client(
|
||||
base_url=f"{base_url.rstrip('/')}/api/v1",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
try:
|
||||
response = self._http.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
raise GiteaError(f"gitea request failed: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise GiteaError(
|
||||
f"gitea {method} {path} returned {response.status_code}: "
|
||||
f"{response.text[:300]}"
|
||||
)
|
||||
return response.json() if response.content else None
|
||||
|
||||
def ssh_url(self, repo: str) -> str:
|
||||
"""The repository's SSH clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["ssh_url"])
|
||||
|
||||
def clone_url(self, repo: str) -> str:
|
||||
"""The repository's HTTPS clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["clone_url"])
|
||||
|
||||
def whoami(self) -> str:
|
||||
return str(self._request("GET", "/user")["login"])
|
||||
|
||||
def open_pull_request(
|
||||
self, repo: str, *, head: str, base: str, title: str, body: str
|
||||
) -> PullRequest:
|
||||
data = self._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/pulls",
|
||||
json={"head": head, "base": base, "title": title, "body": body},
|
||||
)
|
||||
return _pull_request(data)
|
||||
|
||||
def pull_request(self, repo: str, index: int) -> PullRequest:
|
||||
return _pull_request(self._request("GET", f"/repos/{repo}/pulls/{index}"))
|
||||
|
||||
def review_notes(self, repo: str, index: int) -> list[ReviewNote]:
|
||||
"""Review bodies and inline line comments on a PR (#69): the PR's
|
||||
conversation comments come from list_comments."""
|
||||
notes: list[ReviewNote] = []
|
||||
for review in (
|
||||
self._request("GET", f"/repos/{repo}/pulls/{index}/reviews") or []
|
||||
):
|
||||
author = (review.get("user") or {}).get("login", "")
|
||||
body = str(review.get("body") or "").strip()
|
||||
if body:
|
||||
notes.append(ReviewNote(f"review-{review['id']}", author, body, ""))
|
||||
if not review.get("comments_count"):
|
||||
continue
|
||||
for comment in (
|
||||
self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/pulls/{index}/reviews/{review['id']}/comments",
|
||||
)
|
||||
or []
|
||||
):
|
||||
line = comment.get("line") or comment.get("position") or ""
|
||||
notes.append(
|
||||
ReviewNote(
|
||||
key=f"line-{comment['id']}",
|
||||
author=(comment.get("user") or {}).get("login", author),
|
||||
body=str(comment.get("body") or ""),
|
||||
where=f"{comment.get('path', '')}:{line}",
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
||||
"""External truth for L8: did the PR for this branch already get
|
||||
opened? Includes closed PRs — a crash after opening must not be
|
||||
answered with "no PR is open, so open another".
|
||||
|
||||
Pages to the end rather than sampling the first page: a "not found"
|
||||
that only means "not in the newest 50" would licence a duplicate PR.
|
||||
If the history is longer than this walk, raise instead of guessing —
|
||||
the reconciler turns that into "unverifiable" and gates to the human.
|
||||
"""
|
||||
for page in range(1, MAX_PR_PAGES + 1):
|
||||
data = self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/pulls",
|
||||
params={"state": "all", "limit": PR_PAGE_SIZE, "page": page},
|
||||
)
|
||||
items = data or []
|
||||
for item in items:
|
||||
if (item.get("head") or {}).get("ref") == head:
|
||||
return _pull_request(item)
|
||||
if len(items) < PR_PAGE_SIZE:
|
||||
return None # reached the end: definitively absent
|
||||
raise GiteaError(
|
||||
f"searched {MAX_PR_PAGES * PR_PAGE_SIZE} pull requests in {repo} "
|
||||
f"without reaching the end; cannot confirm whether {head!r} was "
|
||||
"already opened"
|
||||
)
|
||||
|
||||
def list_comments(
|
||||
self, repo: str, index: int, since: str | None = None
|
||||
) -> list[Comment]:
|
||||
params: dict[str, str] = {}
|
||||
if since is not None:
|
||||
params["since"] = since
|
||||
data = self._request(
|
||||
"GET", f"/repos/{repo}/issues/{index}/comments", params=params
|
||||
)
|
||||
return [
|
||||
Comment(
|
||||
id=item["id"],
|
||||
author=(item.get("user") or {}).get("login", ""),
|
||||
body=item.get("body", ""),
|
||||
created_at=item.get("created_at", ""),
|
||||
)
|
||||
for item in data or []
|
||||
]
|
||||
|
||||
def post_comment(self, repo: str, index: int, body: str) -> Comment:
|
||||
data = self._request(
|
||||
"POST", f"/repos/{repo}/issues/{index}/comments", json={"body": body}
|
||||
)
|
||||
return Comment(
|
||||
id=data["id"],
|
||||
author=(data.get("user") or {}).get("login", ""),
|
||||
body=data.get("body", ""),
|
||||
created_at=data.get("created_at", ""),
|
||||
)
|
||||
|
||||
def create_issue(self, repo: str, title: str, body: str) -> int:
|
||||
data = self._request(
|
||||
"POST", f"/repos/{repo}/issues", json={"title": title, "body": body}
|
||||
)
|
||||
return int(data["number"])
|
||||
|
||||
def close_pull_request(self, repo: str, index: int) -> None:
|
||||
self._request("PATCH", f"/repos/{repo}/pulls/{index}", json={"state": "closed"})
|
||||
|
||||
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
|
||||
"""Open issues (not PRs) assigned to ``assignee``: intake (D1)."""
|
||||
data = self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues",
|
||||
params={
|
||||
"type": "issues",
|
||||
"state": "open",
|
||||
"assigned_by": assignee,
|
||||
"limit": 50,
|
||||
},
|
||||
)
|
||||
return [
|
||||
Issue(
|
||||
number=int(item["number"]),
|
||||
title=str(item.get("title") or ""),
|
||||
body=str(item.get("body") or ""),
|
||||
author=str(item["user"]["login"]),
|
||||
labels=tuple(str(label["name"]) for label in item.get("labels") or []),
|
||||
)
|
||||
for item in data or []
|
||||
if not item.get("pull_request")
|
||||
]
|
||||
|
||||
def open_dependencies(self, repo: str, index: int) -> list[str]:
|
||||
"""The issues this one depends on that are still open, as
|
||||
"owner/repo#N": what blocks it (L1)."""
|
||||
data = self._request("GET", f"/repos/{repo}/issues/{index}/dependencies")
|
||||
return [
|
||||
f"{(item.get('repository') or {}).get('full_name', repo)}#{item['number']}"
|
||||
for item in data or []
|
||||
if item.get("state") == "open"
|
||||
]
|
||||
|
||||
def assigned_by(self, repo: str, index: int, assignee: str) -> str | None:
|
||||
"""Who last assigned the issue to ``assignee``, from its timeline;
|
||||
None if the timeline doesn't say."""
|
||||
events = self._request("GET", f"/repos/{repo}/issues/{index}/timeline") or []
|
||||
who = None
|
||||
for event in events:
|
||||
if (
|
||||
event.get("type") == "assignees"
|
||||
and not event.get("removed_assignee")
|
||||
and (event.get("assignee") or {}).get("login") == assignee
|
||||
):
|
||||
who = (event.get("user") or {}).get("login")
|
||||
return who
|
||||
|
||||
def assigned_at(self, repo: str, index: int, assignee: str) -> str | None:
|
||||
"""When the issue was last assigned to ``assignee`` (ISO 8601), from
|
||||
its timeline, oldest first and paged to the end; None if the
|
||||
timeline doesn't say."""
|
||||
when = None
|
||||
for page in range(1, MAX_PR_PAGES + 1):
|
||||
events = (
|
||||
self._request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues/{index}/timeline",
|
||||
params={"limit": PR_PAGE_SIZE, "page": page},
|
||||
)
|
||||
or []
|
||||
)
|
||||
for event in events:
|
||||
if (
|
||||
event.get("type") == "assignees"
|
||||
and not event.get("removed_assignee")
|
||||
and (event.get("assignee") or {}).get("login") == assignee
|
||||
):
|
||||
when = event.get("created_at")
|
||||
if len(events) < PR_PAGE_SIZE:
|
||||
break
|
||||
return when
|
||||
|
||||
def approvals(self, repo: str, index: int) -> list[Approval]:
|
||||
"""The PR's approving reviews that still stand: not dismissed."""
|
||||
return [
|
||||
Approval(
|
||||
author=(review.get("user") or {}).get("login", ""),
|
||||
commit=str(review.get("commit_id") or ""),
|
||||
stale=bool(review.get("stale")),
|
||||
)
|
||||
for review in (
|
||||
self._request("GET", f"/repos/{repo}/pulls/{index}/reviews") or []
|
||||
)
|
||||
if review.get("state") == "APPROVED" and not review.get("dismissed")
|
||||
]
|
||||
|
||||
def checks(self, repo: str, sha: str) -> str:
|
||||
"""A commit's combined check status: success, pending, failure or
|
||||
error; "none" when no check reported on it at all."""
|
||||
data = self._request("GET", f"/repos/{repo}/commits/{sha}/status") or {}
|
||||
if not data.get("total_count") and not data.get("statuses"):
|
||||
return "none"
|
||||
return str(data.get("state") or "pending")
|
||||
|
||||
def branch_with_file(
|
||||
self, repo: str, *, base: str, branch: str, path: str, text: str, message: str
|
||||
) -> None:
|
||||
"""A new branch off ``base`` with one file added, made through the
|
||||
API: no clone, no workspace."""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/repos/{repo}/contents/{path}",
|
||||
json={
|
||||
"content": base64.b64encode(text.encode()).decode(),
|
||||
"message": message,
|
||||
"branch": base,
|
||||
"new_branch": branch,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_branch(self, repo: str, name: str) -> None:
|
||||
self._request("DELETE", f"/repos/{repo}/branches/{name}")
|
||||
|
||||
def repository(self, repo: str) -> dict[str, Any]:
|
||||
"""The repository as this account sees it, permissions included."""
|
||||
return dict(self._request("GET", f"/repos/{repo}"))
|
||||
|
||||
def branch(self, repo: str, name: str) -> dict[str, Any]:
|
||||
"""A branch as this account sees it: protected, user_can_push."""
|
||||
return dict(self._request("GET", f"/repos/{repo}/branches/{name}"))
|
||||
|
||||
def merge_pull_request(self, repo: str, index: int) -> None:
|
||||
self._request(
|
||||
"POST", f"/repos/{repo}/pulls/{index}/merge", json={"Do": "merge"}
|
||||
)
|
||||
|
||||
def assign_issue(self, repo: str, index: int, assignee: str) -> None:
|
||||
self._request(
|
||||
"PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]}
|
||||
)
|
||||
|
||||
def close_issue(self, repo: str, index: int) -> None:
|
||||
self._request(
|
||||
"PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"}
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
def _pull_request(data: dict[str, Any]) -> PullRequest:
|
||||
return PullRequest(
|
||||
number=data["number"],
|
||||
state=data.get("state", ""),
|
||||
head=(data.get("head") or {}).get("ref", ""),
|
||||
url=data.get("html_url", ""),
|
||||
merged=bool(data.get("merged")),
|
||||
head_sha=(data.get("head") or {}).get("sha", ""),
|
||||
mergeable=data.get("mergeable"),
|
||||
)
|
||||
|
||||
|
||||
def pull_request_reconciler(client: GiteaClient, repo: str) -> Reconciler:
|
||||
"""Reconciler for an ``open_pr`` intent (L8 boot step 2).
|
||||
|
||||
An unreachable Gitea yields None — unverifiable, so the agent gates to
|
||||
the human rather than risking a duplicate PR. That is the conservative
|
||||
direction: a delayed decision costs nothing, a repeated side effect does.
|
||||
"""
|
||||
|
||||
def check(intent: Intent) -> bool | None:
|
||||
branch = intent.payload.get("branch")
|
||||
if not branch:
|
||||
return None
|
||||
try:
|
||||
return client.find_pull_request(repo, branch) is not None
|
||||
except GiteaError:
|
||||
return None
|
||||
|
||||
return check
|
||||
@@ -1,441 +0,0 @@
|
||||
"""LLM access (architecture L9).
|
||||
|
||||
One client interface, present from the first real LLM call. All traffic goes
|
||||
to the LiteLLM proxy — the VM's single LLM destination; the agent holds one
|
||||
LiteLLM token and no vendor credentials. The base URL and token come from the
|
||||
agent's config (nisse_dev.config), nothing hardcoded.
|
||||
|
||||
Model identity is expressed as roles (L9), resolved to concrete model strings
|
||||
from config; the resolved mapping is pinned at task start so a mid-task
|
||||
config change cannot split a task across two planners.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
mint_call_id,
|
||||
)
|
||||
from nisse_dev.render import OpenAIChatRenderer
|
||||
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# A rate limit is a transient condition, not a bad request. Losing a
|
||||
# ten-turn delegation to one wastes far more tokens than waiting out the
|
||||
# window, so retry once before surfacing it (L9's caps still bound the
|
||||
# total).
|
||||
RATE_LIMIT_STATUS = 429
|
||||
RATE_LIMIT_RETRY_S = 20.0
|
||||
|
||||
# The proxy, or what's behind it, briefly gone: a 502/503/504 or a dropped
|
||||
# connection. Outages delay, never fail (L5): waited out with backoff before
|
||||
# the work is stopped. Not a 500 (it may be the request) and not a timeout
|
||||
# (a hung model has had its request_timeout_s already).
|
||||
OUTAGE_STATUSES = frozenset({502, 503, 504})
|
||||
OUTAGE_BACKOFF_S = (5.0, 15.0, 45.0)
|
||||
|
||||
# Generous by default. Output is billed by what is produced, not by the
|
||||
# ceiling, and a ceiling too low does not fail loudly — it truncates a tool
|
||||
# call mid-argument and hands back something that parses. Measured: Opus
|
||||
# writing an architecture plan routinely spends 3,000-4,000 output tokens and
|
||||
# was observed at 4,064 against a 4,096 limit.
|
||||
DEFAULT_MAX_OUTPUT_TOKENS = 16384
|
||||
|
||||
# How long one request may take before it is abandoned (llm.request_timeout_s).
|
||||
# A safety net for a hung connection, not a budget: a dense local model
|
||||
# writing a whole module in one turn was measured taking over 120 s, and
|
||||
# timing it out failed work that was going well.
|
||||
DEFAULT_REQUEST_TIMEOUT_S = 600.0
|
||||
|
||||
|
||||
class UpstreamUnavailable(LLMError):
|
||||
"""The proxy or provider could not be reached, or refused for reasons
|
||||
unrelated to what was asked: a network blip, a timeout, a 5xx, a rate
|
||||
limit that did not clear, an exhausted budget.
|
||||
|
||||
Worth distinguishing because the *work* is not at fault. A delegation
|
||||
stopped by one of these has a branch that may be perfectly good, so it is
|
||||
preserved and resumed rather than discarded — unlike a delegate that made
|
||||
a mess, which is cleaned up.
|
||||
"""
|
||||
|
||||
|
||||
class BudgetExhausted(UpstreamUnavailable):
|
||||
"""The proxy refused because a spend limit is gone.
|
||||
|
||||
Distinct from the rest of its family because it will not clear on its
|
||||
own within the hour: an exhausted daily budget takes external models away
|
||||
until it resets, so retrying wastes time and the operator needs telling.
|
||||
L9 keeps the budget itself at the proxy; recognising its refusal is ours.
|
||||
"""
|
||||
|
||||
|
||||
def _looks_like_budget_refusal(text: str) -> bool:
|
||||
# Pattern-matched rather than contracted: LiteLLM's wording is not a
|
||||
# documented interface, so this errs toward missing one rather than
|
||||
# misreporting an unrelated failure as a budget problem.
|
||||
lowered = text.lower()
|
||||
return "budget" in lowered and (
|
||||
"exceed" in lowered or "exhaust" in lowered or "limit" in lowered
|
||||
)
|
||||
|
||||
|
||||
class TruncatedResponse(LLMError):
|
||||
"""The model was cut off mid-answer.
|
||||
|
||||
Raised rather than returned, because the damage is silent: a truncated
|
||||
tool call arrives with required arguments simply absent, and a truncated
|
||||
write_file would write half a file and report success. A cut-off turn is
|
||||
a failed turn, and every caller already retries or gates on LLMError.
|
||||
"""
|
||||
|
||||
|
||||
class LLMClient(Protocol):
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> list[CanonicalEvent]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleMap:
|
||||
driver: str # drives the loop (L2)
|
||||
helper: str # delegations (L9)
|
||||
expert: str # ask_expert (L9)
|
||||
classifier: str = "" # reads go-aheads now, actions later (L4); unset, expert's
|
||||
|
||||
ROLES: ClassVar[tuple[str, ...]] = ("driver", "helper", "expert", "classifier")
|
||||
_FALLBACK: ClassVar[dict[str, str]] = {"classifier": "expert"}
|
||||
|
||||
def resolve(self, role: str) -> str:
|
||||
if role not in self.ROLES:
|
||||
raise LLMError(f"unknown role: {role!r}")
|
||||
model = str(getattr(self, role))
|
||||
if not model and role in self._FALLBACK:
|
||||
return self.resolve(self._FALLBACK[role])
|
||||
return model
|
||||
|
||||
def pinned(self) -> dict[str, str]:
|
||||
"""The full resolved mapping, to record at task start (L9)."""
|
||||
return {role: self.resolve(role) for role in self.ROLES}
|
||||
|
||||
@classmethod
|
||||
def from_pinned(cls, pinned: dict[str, str], default: "RoleMap") -> "RoleMap":
|
||||
"""A work item's pinned mapping; a role it predates comes from
|
||||
``default``."""
|
||||
return cls(**{r: pinned.get(r) or default.resolve(r) for r in cls.ROLES})
|
||||
|
||||
|
||||
class LiteLLMClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
sleep: Any = time.sleep,
|
||||
max_tokens: int | None = None,
|
||||
) -> None:
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url.rstrip("/"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
)
|
||||
self._renderer = OpenAIChatRenderer()
|
||||
self._sleep = sleep
|
||||
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
|
||||
# Set after each call so a caller can meter itself (L9 caps);
|
||||
# a side channel rather than a return value, so the LLMClient
|
||||
# protocol stays the same for every implementation.
|
||||
self.last_usage: dict[str, int] = {}
|
||||
self.last_model: str | None = None
|
||||
self.last_cost: float = 0.0
|
||||
# Set even when the call then fails: time spent waiting out a rate
|
||||
# limit is the proxy's, not the model's (the benchmark subtracts it).
|
||||
self.last_rate_limit_wait_s: float = 0.0
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int | None = None,
|
||||
require_tool_call: bool = False,
|
||||
) -> list[CanonicalEvent]:
|
||||
body = self._renderer.render(events, tools, model, system=system)
|
||||
if require_tool_call and tools:
|
||||
# Only for models whose server enforces it; see BudgetedClient.
|
||||
body["tool_choice"] = "required"
|
||||
# A ceiling on this route too: without one a runaway turn is bounded
|
||||
# only by the provider's default, and the same truncation check has
|
||||
# nothing to check against. A caller that knows the model's limits
|
||||
# (the context budget) passes a tighter one.
|
||||
body["max_tokens"] = max_tokens or self._max_tokens
|
||||
self.last_rate_limit_wait_s = 0.0 # not the last call's, if this one raises
|
||||
response, self.last_rate_limit_wait_s = _post_with_rate_limit_retry(
|
||||
self._http, "/chat/completions", body, self._sleep
|
||||
)
|
||||
if response.status_code != 200:
|
||||
_raise_for_upstream(response)
|
||||
raise LLMError(
|
||||
f"LLM request returned {response.status_code}: {response.text[:500]}"
|
||||
)
|
||||
# LiteLLM prices every call; kept so a caller can meter spending.
|
||||
self.last_cost = _response_cost(response)
|
||||
return self._parse(response.json(), requested_model=model)
|
||||
|
||||
def _parse(
|
||||
self, data: dict[str, Any], requested_model: str
|
||||
) -> list[CanonicalEvent]:
|
||||
try:
|
||||
choice = data["choices"][0]
|
||||
message = choice["message"]
|
||||
except (KeyError, IndexError) as exc:
|
||||
raise LLMError(f"malformed completion response: {exc}") from exc
|
||||
if choice.get("finish_reason") == "length":
|
||||
raise TruncatedResponse(
|
||||
f"{requested_model} hit its output limit mid-answer; raise "
|
||||
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
|
||||
)
|
||||
produced_by = data.get("model") or requested_model
|
||||
# What actually answered, which is not always what was asked for: a
|
||||
# proxy fallback answers under another model's name.
|
||||
self.last_model = produced_by
|
||||
usage = data.get("usage") or {}
|
||||
self.last_usage = {
|
||||
"input": int(usage.get("prompt_tokens") or 0),
|
||||
"output": int(usage.get("completion_tokens") or 0),
|
||||
}
|
||||
out: list[CanonicalEvent] = []
|
||||
if message.get("content"):
|
||||
out.append(AssistantText(text=message["content"], model=produced_by))
|
||||
# Provider tool-call IDs are mapped at the boundary (L7): each call
|
||||
# gets a locally-minted canonical ID; the provider's is dropped, since
|
||||
# every future request is rendered wholly from the canonical log.
|
||||
for call in message.get("tool_calls") or []:
|
||||
raw = call["function"]["arguments"] or "{}"
|
||||
# Weak models emit truncated or non-JSON argument strings; that is
|
||||
# a bad model turn, not a client crash (L9).
|
||||
try:
|
||||
arguments = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LLMError(
|
||||
f"tool call {call['function']['name']!r} had non-JSON "
|
||||
f"arguments: {raw[:200]!r}"
|
||||
) from exc
|
||||
if not isinstance(arguments, dict):
|
||||
raise LLMError(
|
||||
f"tool call {call['function']['name']!r} arguments were "
|
||||
f"{type(arguments).__name__}, expected object"
|
||||
)
|
||||
out.append(
|
||||
ToolCall(
|
||||
call_id=mint_call_id(),
|
||||
tool=call["function"]["name"],
|
||||
arguments=arguments,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
class AnthropicPassthroughClient:
|
||||
"""LiteLLM's Anthropic passthrough (L9). Same interface as LiteLLMClient,
|
||||
so a caller chooses a route rather than a different kind of client.
|
||||
|
||||
Delegations take this path because fidelity matters there (L9);
|
||||
ask_expert and fallbacks use the normalized route, where it does not.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S,
|
||||
max_tokens: int | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
sleep: Any = time.sleep,
|
||||
) -> None:
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url.rstrip("/"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"x-api-key": token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
)
|
||||
self._renderer = AnthropicMessagesRenderer()
|
||||
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
|
||||
self._sleep = sleep
|
||||
self.last_usage: dict[str, int] = {}
|
||||
self.last_model: str | None = None
|
||||
self.last_cost: float = 0.0
|
||||
# Set even when the call then fails: time spent waiting out a rate
|
||||
# limit is the proxy's, not the model's (the benchmark subtracts it).
|
||||
self.last_rate_limit_wait_s: float = 0.0
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int | None = None,
|
||||
require_tool_call: bool = False,
|
||||
) -> list[CanonicalEvent]:
|
||||
body = self._renderer.render(
|
||||
events,
|
||||
tools,
|
||||
model,
|
||||
system=system,
|
||||
max_tokens=max_tokens or self._max_tokens,
|
||||
)
|
||||
if require_tool_call and tools:
|
||||
body["tool_choice"] = {"type": "any"} # Anthropic's name for it
|
||||
self.last_rate_limit_wait_s = 0.0 # not the last call's, if this one raises
|
||||
response, self.last_rate_limit_wait_s = _post_with_rate_limit_retry(
|
||||
self._http, "/v1/messages", body, self._sleep
|
||||
)
|
||||
if response.status_code != 200:
|
||||
_raise_for_upstream(response)
|
||||
raise LLMError(
|
||||
f"passthrough returned {response.status_code}: {response.text[:500]}"
|
||||
)
|
||||
# LiteLLM prices every call; kept so a caller can meter spending.
|
||||
self.last_cost = _response_cost(response)
|
||||
return self._parse(response.json(), requested_model=model)
|
||||
|
||||
def _parse(
|
||||
self, data: dict[str, Any], requested_model: str
|
||||
) -> list[CanonicalEvent]:
|
||||
if data.get("stop_reason") == "max_tokens":
|
||||
raise TruncatedResponse(
|
||||
f"{requested_model} hit its output limit mid-answer; raise "
|
||||
"llm.max_output_tokens, or the model's max_output_tokens in LiteLLM"
|
||||
)
|
||||
content = data.get("content")
|
||||
if not isinstance(content, list):
|
||||
raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
|
||||
produced_by = data.get("model") or requested_model
|
||||
# What actually answered, which is not always what was asked for: a
|
||||
# proxy fallback answers under another model's name.
|
||||
self.last_model = produced_by
|
||||
usage = data.get("usage") or {}
|
||||
self.last_usage = {
|
||||
"input": int(usage.get("input_tokens") or 0),
|
||||
"output": int(usage.get("output_tokens") or 0),
|
||||
}
|
||||
out: list[CanonicalEvent] = []
|
||||
for block in content:
|
||||
kind = block.get("type")
|
||||
if kind == "text" and block.get("text"):
|
||||
out.append(AssistantText(text=block["text"], model=produced_by))
|
||||
elif kind == "tool_use":
|
||||
# Provider ids are mapped at the boundary and never used as a
|
||||
# join key (L7); the canonical log owns identity.
|
||||
out.append(
|
||||
ToolCall(
|
||||
call_id=mint_call_id(),
|
||||
tool=block["name"],
|
||||
arguments=block.get("input") or {},
|
||||
)
|
||||
)
|
||||
# Vendor-opaque reasoning blocks (thinking, redacted_thinking) are
|
||||
# deliberately dropped for now: L7 requires storing them tagged
|
||||
# with the producing model and never replaying them outside the
|
||||
# session that made them, and the canonical schema has no event
|
||||
# for that yet. Nothing requests extended thinking, so none arrive.
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
def _post_with_rate_limit_retry(
|
||||
http: httpx.Client, path: str, body: dict[str, Any], sleep: Any
|
||||
) -> tuple[httpx.Response, float]:
|
||||
"""The response, and how long was spent waiting: out a rate limit (once)
|
||||
or an outage (with backoff), not on the model."""
|
||||
waited = 0.0
|
||||
rate_limited = False
|
||||
outages = iter(OUTAGE_BACKOFF_S)
|
||||
while True:
|
||||
try:
|
||||
response = http.post(path, json=body)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise UpstreamUnavailable(f"LLM request failed: {exc}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
# Could not reach the proxy at all: nothing about the request was
|
||||
# wrong, so callers holding work in progress should keep it.
|
||||
pause = next(outages, None)
|
||||
if pause is None:
|
||||
raise UpstreamUnavailable(f"LLM request failed: {exc}") from exc
|
||||
sleep(pause)
|
||||
waited += pause
|
||||
continue
|
||||
if response.status_code == RATE_LIMIT_STATUS and not rate_limited:
|
||||
rate_limited = True
|
||||
sleep(RATE_LIMIT_RETRY_S)
|
||||
waited += RATE_LIMIT_RETRY_S
|
||||
continue
|
||||
if response.status_code in OUTAGE_STATUSES and not _looks_like_budget_refusal(
|
||||
response.text
|
||||
):
|
||||
pause = next(outages, None)
|
||||
if pause is not None:
|
||||
sleep(pause)
|
||||
waited += pause
|
||||
continue
|
||||
return response, waited
|
||||
|
||||
|
||||
# Model families we have a native renderer adapter for. L9: delegations take
|
||||
# the vendor's passthrough route because a normalising middlebox is lossy
|
||||
# exactly where a long-running delegation lives.
|
||||
def _response_cost(response: httpx.Response) -> float:
|
||||
try:
|
||||
return float(response.headers.get("x-litellm-response-cost") or 0.0)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _raise_for_upstream(response: httpx.Response) -> None:
|
||||
"""Classify a non-200 as upstream trouble where it plainly is.
|
||||
|
||||
A 4xx that is not a rate limit means the request itself was wrong, and
|
||||
preserving work on the strength of one would hide a real bug — so the
|
||||
benefit of the doubt stops at the server's own door.
|
||||
"""
|
||||
if _looks_like_budget_refusal(response.text):
|
||||
raise BudgetExhausted(
|
||||
f"the proxy refused on budget grounds: {response.text[:300]}"
|
||||
)
|
||||
if response.status_code >= 500 or response.status_code == RATE_LIMIT_STATUS:
|
||||
raise UpstreamUnavailable(
|
||||
f"upstream returned {response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
@@ -34,8 +34,8 @@ from nisse_core.actions import (
|
||||
Action,
|
||||
Verdict,
|
||||
)
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.canonical import (
|
||||
ActionVerdict,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
@@ -46,13 +46,19 @@ from nisse_dev.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.execution import HostKeyChanged, HostUnavailable
|
||||
from nisse_core.gitea import GiteaClient, GiteaError, Issue, PullRequest
|
||||
from nisse_core.llm import LLMError, RoleMap
|
||||
from nisse_core.memory import Memory
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state import WorkItem
|
||||
from nisse_core.transport import InboundMessage, Transport
|
||||
from nisse_core.vault import Vault, VaultError
|
||||
from nisse_core.worker import Worker
|
||||
from nisse_dev.config import GENERAL
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Delegation
|
||||
from nisse_dev.execution import HostKeyChanged, HostUnavailable
|
||||
from nisse_dev.expert import BudgetExhausted, ExpertDesk
|
||||
from nisse_dev.git import GitError
|
||||
from nisse_dev.gitea import GiteaClient, GiteaError, Issue, PullRequest
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.loop import preflight
|
||||
from nisse_dev.loop.actions import classify, commits
|
||||
from nisse_dev.loop.classifier import IN_SCOPE, ActionClassifier, Scope
|
||||
@@ -99,13 +105,8 @@ from nisse_dev.loop.tools import (
|
||||
TOOLCHAIN_STEP,
|
||||
WAIT_FOR_REVIEW,
|
||||
)
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore, WorkItem
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import EXECUTION_TOOLS, Workspace
|
||||
from nisse_dev.transport import InboundMessage, Transport
|
||||
from nisse_dev.vault import Vault, VaultError
|
||||
from nisse_dev.worker import Worker
|
||||
|
||||
INTAKE_INTERVAL_S = 30.0
|
||||
_GO = frozenset({"go", "approve"})
|
||||
|
||||
@@ -7,29 +7,29 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.catalog import ModelCatalog, select_client
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.catalog import ModelCatalog, select_client
|
||||
from nisse_core.context import BudgetedClient
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.gitea import GiteaClient
|
||||
from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_core.matrix import MatrixTransport
|
||||
from nisse_core.memory import Memory
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.vault import Vault
|
||||
from nisse_core.worker import SerialWorker
|
||||
from nisse_dev.config import AgentConfig, ConfigError
|
||||
from nisse_dev.config import Project as ProjectEntry
|
||||
from nisse_dev.context import BudgetedClient
|
||||
from nisse_dev.delegation import Caps
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
|
||||
from nisse_dev.gitea import GiteaClient
|
||||
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.classifier import ActionClassifier
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.help import Helpers
|
||||
from nisse_dev.loop.milestones import Milestones
|
||||
from nisse_dev.loop.reader import GoAheadReader
|
||||
from nisse_dev.matrix import MatrixTransport
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.vault import Vault
|
||||
from nisse_dev.worker import SerialWorker
|
||||
|
||||
# How long shutdown waits for running work before leaving the store to the
|
||||
# durability layer (L8). A milestone can run far longer than a service
|
||||
@@ -301,7 +301,12 @@ def build_loop_agent(
|
||||
)
|
||||
transport = MatrixTransport(config.matrix)
|
||||
vault = (
|
||||
Vault(config.vault.root, config.vault.folders, ignore=config.vault.ignore)
|
||||
Vault(
|
||||
config.vault.root,
|
||||
config.vault.folders,
|
||||
agent="nisse-dev",
|
||||
ignore=config.vault.ignore,
|
||||
)
|
||||
if config.vault
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from nisse_core.actions import CONTAINED, DANGEROUS, Action, Verdict
|
||||
from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
from nisse_core.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_core.llm import LLMClient, LLMError
|
||||
|
||||
SAFE = "safe"
|
||||
IN_SCOPE = "in_scope"
|
||||
|
||||
@@ -11,11 +11,11 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from nisse_dev.cancel import Cancel, skip_calls
|
||||
from nisse_dev.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
from nisse_core.cancel import Cancel, skip_calls
|
||||
from nisse_core.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||
from nisse_core.llm import LLMClient, LLMError
|
||||
from nisse_core.state import Transcript
|
||||
from nisse_dev.loop.tools import WAITING, names
|
||||
from nisse_dev.state import Transcript
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
DEFAULT_MAX_TURNS = 30
|
||||
|
||||
@@ -11,10 +11,10 @@ half-edited tree).
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from nisse_core.llm import LLMClient, RoleMap
|
||||
from nisse_dev.config import Profile
|
||||
from nisse_dev.delegation import Caps
|
||||
from nisse_dev.expert import DEFAULT_BUDGET
|
||||
from nisse_dev.llm import LLMClient, RoleMap
|
||||
from nisse_dev.loop.tools import _tool
|
||||
|
||||
DELEGATE = _tool(
|
||||
|
||||
@@ -8,7 +8,8 @@ agreement, what it is waiting for) is in ``data["loop"]``.
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.state import StateStore, WorkItem
|
||||
from nisse_core.state import WorkItem
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
READY = "ready"
|
||||
RUNNING = "running" # a driver run is under way; on boot it means interrupted
|
||||
|
||||
@@ -13,8 +13,8 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
|
||||
from nisse_dev.git import GitError, GitRepo, PushCredential
|
||||
from nisse_dev.gitea import GiteaClient, GiteaError, PullRequest
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
OPEN_PR = "open_pr"
|
||||
|
||||
@@ -21,7 +21,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from nisse_core.actions import split_shell, unwrap
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_core.execution import SSHHost
|
||||
|
||||
# What the harness itself runs on a build VM (L13, D4): part of the
|
||||
# template, never of a project's toolchain.
|
||||
|
||||
@@ -10,8 +10,8 @@ The driver's model reads for now; the classifier role takes over in M2.6.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
from nisse_core.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_core.llm import LLMClient, LLMError
|
||||
|
||||
|
||||
def command_form(text: str) -> str:
|
||||
|
||||
@@ -12,8 +12,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
|
||||
from nisse_core.envfile import load_env_file
|
||||
from nisse_dev.config import AgentConfig, ConfigError
|
||||
from nisse_dev.envfile import load_env_file
|
||||
from nisse_dev.loop.build import build_loop_agent
|
||||
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""MatrixTransport (architecture L5): Matrix as strictly a UI channel.
|
||||
|
||||
Implements the Transport interface directly over the Matrix client-server HTTP
|
||||
API (token auth, /sync long-poll, /send) with the httpx client already in the
|
||||
dependency tree — no Matrix SDK. The surface used is four endpoints; an SDK
|
||||
earns its keep when E2E arrives, and bot rooms are created unencrypted until
|
||||
then, so encrypted rooms are not supported here.
|
||||
|
||||
Inbound handling follows L5's intake posture: on homeserver errors the sync
|
||||
loop backs off (capped) and retries indefinitely — an outage delays reactions
|
||||
but never fails anything.
|
||||
|
||||
Threads (L5): each work item's conversation is a Matrix thread. A message in a
|
||||
thread carries its root's event ID; a plain reply from a client without
|
||||
thread support is routed to the thread of the message it replies to, when
|
||||
that is known. The quoted lines such replies carry are not part of the text.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.transport import InboundMessage
|
||||
|
||||
# A mention pill at the start of an HTML body: the link holds the user ID,
|
||||
# the link text is what the plain body starts with.
|
||||
_LEADING_PILL = re.compile(
|
||||
r'\s*<a href="https://matrix\.to/#/([^"?]+)[^"]*">(.*?)</a>', re.DOTALL
|
||||
)
|
||||
# A user ID typed at the start of a plain body, e.g. "@alice:example: ...".
|
||||
_LEADING_USER_ID = re.compile(r"\s*(@[^\s:]+:[^\s:]+)")
|
||||
# How many event-to-thread mappings to remember, for routing plain replies.
|
||||
_REMEMBERED_EVENTS = 2000
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixConfig:
|
||||
homeserver: str
|
||||
user_id: str
|
||||
token: str = field(repr=False) # never in a repr or a log line
|
||||
room_id: str
|
||||
|
||||
|
||||
class MatrixTransport:
|
||||
def __init__(
|
||||
self,
|
||||
config: MatrixConfig,
|
||||
*,
|
||||
poll_timeout_ms: int = 30000,
|
||||
http_transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._poll_timeout_ms = poll_timeout_ms
|
||||
self._http = httpx.Client(
|
||||
base_url=f"{config.homeserver}/_matrix/client/v3",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=(poll_timeout_ms / 1000) + 30,
|
||||
transport=http_transport,
|
||||
)
|
||||
self._callback: Callable[[InboundMessage], None] | None = None
|
||||
# The agent's display name in the room, learnt from its own member
|
||||
# event; clients write it in front of messages addressed to it.
|
||||
self._display_name: str | None = None
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
# event ID -> the root of the thread it is in (a root maps to
|
||||
# itself), for routing replies from clients without thread support.
|
||||
self._threads: OrderedDict[str, str] = OrderedDict()
|
||||
self._threads_lock = threading.Lock()
|
||||
|
||||
def _remember(self, event_id: str, root: str) -> None:
|
||||
with self._threads_lock:
|
||||
self._threads[event_id] = root
|
||||
self._threads.move_to_end(event_id)
|
||||
while len(self._threads) > _REMEMBERED_EVENTS:
|
||||
self._threads.popitem(last=False)
|
||||
|
||||
def _thread_of(self, event_id: str) -> str | None:
|
||||
with self._threads_lock:
|
||||
return self._threads.get(event_id)
|
||||
|
||||
# -- outbound ---------------------------------------------------------
|
||||
|
||||
def post(self, text: str, *, thread: str | None = None) -> str:
|
||||
content: dict = {"msgtype": "m.text", "body": text}
|
||||
if thread is not None:
|
||||
content["m.relates_to"] = {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": thread,
|
||||
# For clients without threads: show it as a reply to the root.
|
||||
"is_falling_back": True,
|
||||
"m.in_reply_to": {"event_id": thread},
|
||||
}
|
||||
event_id = self._send(content)
|
||||
# A top-level post may root a thread; one in a thread belongs to it.
|
||||
self._remember(event_id, thread or event_id)
|
||||
return event_id
|
||||
|
||||
def send_message(self, text: str) -> None:
|
||||
self.post(text)
|
||||
|
||||
def send_approval_request(self, text: str) -> None:
|
||||
self.post(text)
|
||||
|
||||
def ask_question(self, text: str) -> None:
|
||||
self.post(text)
|
||||
|
||||
def _send(self, content: dict) -> str:
|
||||
room = self._config.room_id
|
||||
txn = uuid.uuid4().hex
|
||||
try:
|
||||
response = self._http.put(
|
||||
f"/rooms/{room}/send/m.room.message/{txn}", json=content
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TransportError(f"matrix send failed: {exc}") from exc
|
||||
if response.status_code != 200:
|
||||
raise TransportError(
|
||||
f"matrix send returned {response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
return str(response.json().get("event_id", ""))
|
||||
|
||||
# -- inbound ----------------------------------------------------------
|
||||
|
||||
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
|
||||
self._callback = callback
|
||||
|
||||
def start(self) -> None:
|
||||
"""Begin delivering new room messages to the registered callback.
|
||||
History from before start() is never delivered (Matrix room history
|
||||
must not become load-bearing context, L5)."""
|
||||
if self._callback is None:
|
||||
raise TransportError("no message handler registered; call on_message first")
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=(self._poll_timeout_ms / 1000) + 5)
|
||||
self._thread = None
|
||||
|
||||
def _sync_loop(self) -> None:
|
||||
since: str | None = None
|
||||
backoff = 1.0
|
||||
while not self._stop.is_set():
|
||||
params: dict[str, str] = {}
|
||||
if since is None:
|
||||
params["timeout"] = "0" # first sync: learn position, skip backlog
|
||||
else:
|
||||
params["timeout"] = str(self._poll_timeout_ms)
|
||||
params["since"] = since
|
||||
try:
|
||||
response = self._http.get("/sync", params=params)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
self._stop.wait(backoff)
|
||||
backoff = min(backoff * 2, 60.0)
|
||||
continue
|
||||
backoff = 1.0
|
||||
data = response.json()
|
||||
self._learn_display_name(data)
|
||||
if since is not None:
|
||||
self._deliver(data)
|
||||
since = data.get("next_batch", since)
|
||||
|
||||
def _room(self, sync_data: dict) -> dict:
|
||||
return sync_data.get("rooms", {}).get("join", {}).get(self._config.room_id, {})
|
||||
|
||||
def _learn_display_name(self, sync_data: dict) -> None:
|
||||
room = self._room(sync_data)
|
||||
events = room.get("state", {}).get("events", []) + room.get("timeline", {}).get(
|
||||
"events", []
|
||||
)
|
||||
for event in events:
|
||||
if (
|
||||
event.get("type") == "m.room.member"
|
||||
and event.get("state_key") == self._config.user_id
|
||||
):
|
||||
name = event.get("content", {}).get("displayname")
|
||||
if name:
|
||||
self._display_name = name
|
||||
|
||||
def _deliver(self, sync_data: dict) -> None:
|
||||
for event in self._room(sync_data).get("timeline", {}).get("events", []):
|
||||
if event.get("type") != "m.room.message":
|
||||
continue
|
||||
sender = event.get("sender", "")
|
||||
content = event.get("content", {})
|
||||
event_id = str(event.get("event_id", ""))
|
||||
thread = self._thread_for(content)
|
||||
if event_id:
|
||||
self._remember(event_id, thread or event_id)
|
||||
if sender == self._config.user_id:
|
||||
continue
|
||||
body = content.get("body")
|
||||
if not body:
|
||||
continue
|
||||
if "m.in_reply_to" in content.get("m.relates_to", {}):
|
||||
# A rich reply quotes what it replies to; the quote isn't
|
||||
# what the sender said.
|
||||
body = _strip_reply_fallback(body)
|
||||
content = {**content, "body": body}
|
||||
if not body:
|
||||
continue
|
||||
addressee, text = self._addressing(content)
|
||||
if addressee is not None and addressee != self._config.user_id:
|
||||
# Addressed to someone else: in a shared room, "Nisse Ops:
|
||||
# approve" must not reach this agent as rejection feedback.
|
||||
continue
|
||||
assert self._callback is not None
|
||||
self._callback(
|
||||
InboundMessage(
|
||||
sender=sender,
|
||||
text=text,
|
||||
original=body if text != body else None,
|
||||
event_id=event_id,
|
||||
thread=thread,
|
||||
)
|
||||
)
|
||||
|
||||
def _thread_for(self, content: dict) -> str | None:
|
||||
"""The thread root a message belongs to, or None at top level."""
|
||||
relates = content.get("m.relates_to") or {}
|
||||
if relates.get("rel_type") == "m.thread" and relates.get("event_id"):
|
||||
return str(relates["event_id"])
|
||||
replied = (relates.get("m.in_reply_to") or {}).get("event_id")
|
||||
if replied:
|
||||
# A plain reply: in the thread of whatever it replies to, if known.
|
||||
return self._thread_of(str(replied))
|
||||
return None
|
||||
|
||||
def _addressing(self, content: dict) -> tuple[str | None, str]:
|
||||
"""Who a leading mention addresses, and the body without a leading
|
||||
mention of this agent. Only the start of the message counts: a
|
||||
mention later on ("approve, cc @alice") is part of the text. The
|
||||
sender is never read from here; it comes from the event (L3)."""
|
||||
body: str = content["body"]
|
||||
own = self._config.user_id
|
||||
if content.get("format") == "org.matrix.custom.html":
|
||||
pill = _LEADING_PILL.match(content.get("formatted_body", ""))
|
||||
if pill:
|
||||
addressee = unquote(pill.group(1))
|
||||
label = html.unescape(re.sub(r"<[^>]+>", "", pill.group(2)))
|
||||
if addressee == own and label and body.startswith(label):
|
||||
return own, _after_prefix(body, len(label))
|
||||
return addressee, body
|
||||
if body.startswith(own):
|
||||
return own, _after_prefix(body, len(own))
|
||||
name = self._display_name
|
||||
if name and body.startswith(name) and body[len(name) :].startswith(":"):
|
||||
return own, _after_prefix(body, len(name))
|
||||
typed = _LEADING_USER_ID.match(body)
|
||||
if typed and body[typed.end() :].startswith(":"):
|
||||
return typed.group(1), body
|
||||
return None, body
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop()
|
||||
self._http.close()
|
||||
|
||||
|
||||
def _strip_reply_fallback(body: str) -> str:
|
||||
"""The body of a rich reply without the quoted lines clients prepend
|
||||
("> <@alice:example> original text", then a blank line)."""
|
||||
lines = body.split("\n")
|
||||
if not lines or not lines[0].startswith("> "):
|
||||
return body
|
||||
index = 0
|
||||
while index < len(lines) and lines[index].startswith(">"):
|
||||
index += 1
|
||||
if index < len(lines) and not lines[index].strip():
|
||||
index += 1
|
||||
return "\n".join(lines[index:]).strip()
|
||||
|
||||
|
||||
def _after_prefix(body: str, length: int) -> str:
|
||||
"""The body past a leading mention and the ':' clients put after it."""
|
||||
return body[length:].lstrip().removeprefix(":").strip()
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Agent memory (architecture L6.4; Development Plan M5.2).
|
||||
|
||||
The agent's own notes in its vault folder: one note per fact, written and
|
||||
forgotten by the model without asking, and an index the harness keeps. The
|
||||
operator reads it in SilverBullet and corrects it through conversation.
|
||||
Memory is a cache, never authority: what it says is checked against the live
|
||||
system before anything depends on it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
from nisse_dev.vault import Vault, VaultError, split_frontmatter
|
||||
|
||||
INDEX = "index" # the note the harness keeps; never the model's
|
||||
_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""The indexes as read, for the transcript (L6: with their provenance)."""
|
||||
|
||||
paths: tuple[str, ...]
|
||||
text: str
|
||||
sha: str
|
||||
|
||||
|
||||
class Memory:
|
||||
"""``folder``: the agent's read-write folder in the vault. General
|
||||
notes live there; a project's under ``projects/<project>/`` (L6.3), each
|
||||
folder with an index the harness keeps."""
|
||||
|
||||
def __init__(self, vault: Vault, folder: str) -> None:
|
||||
self._vault = vault
|
||||
self.folder = folder.strip("/")
|
||||
|
||||
def _where(self, project: str | None) -> str:
|
||||
if project is None:
|
||||
return self.folder
|
||||
if not _NAME.fullmatch(project):
|
||||
raise VaultError(f"{project!r}: not a project's name")
|
||||
return f"{self.folder}/projects/{project}"
|
||||
|
||||
def _path(self, name: str, project: str | None) -> str:
|
||||
if not _NAME.fullmatch(name) or name == INDEX:
|
||||
raise VaultError(
|
||||
f"{name!r}: a note's name is lowercase letters, digits and -, "
|
||||
f"up to 64, and not {INDEX}"
|
||||
)
|
||||
return f"{self._where(project)}/{name}.md"
|
||||
|
||||
def remember(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
fact: str,
|
||||
provenance: Mapping[str, str],
|
||||
project: str | None = None,
|
||||
) -> bool:
|
||||
"""Write the note, or replace it; True if it existed. ``project``:
|
||||
a note about that project, in its folder; None: a general one."""
|
||||
description = " ".join(description.split())
|
||||
if not description or not fact.strip():
|
||||
raise VaultError("a note needs a one-line description and the fact")
|
||||
path = self._path(name, project)
|
||||
existed = path in self._vault.notes(self.folder)
|
||||
front = yaml.safe_dump({"description": description}, allow_unicode=True)
|
||||
text = f"---\n{front}---\n{fact.strip()}\n"
|
||||
self._vault.write(path, text, provenance=provenance)
|
||||
self._rebuild(project)
|
||||
return existed
|
||||
|
||||
def forget(self, name: str, project: str | None = None) -> None:
|
||||
self._vault.delete(self._path(name, project))
|
||||
self._rebuild(project)
|
||||
|
||||
def _entries(self, where: str) -> list[tuple[str, str]]:
|
||||
"""The notes directly in ``where``: their pages and descriptions."""
|
||||
entries = []
|
||||
for path in self._vault.notes(self.folder):
|
||||
page = path.removesuffix(".md")
|
||||
if page.rsplit("/", 1)[0] != where or page.endswith(f"/{INDEX}"):
|
||||
continue
|
||||
try:
|
||||
front, _ = split_frontmatter(self._vault.read(path))
|
||||
except VaultError:
|
||||
front = {}
|
||||
entries.append((page, str(front.get("description", "")).strip()))
|
||||
return entries
|
||||
|
||||
def _projects(self) -> list[str]:
|
||||
prefix = f"{self.folder}/projects/"
|
||||
return sorted(
|
||||
{
|
||||
p.removeprefix(prefix).split("/", 1)[0]
|
||||
for p in self._vault.notes(self.folder)
|
||||
if p.startswith(prefix) and not p.endswith(f"/{INDEX}.md")
|
||||
}
|
||||
)
|
||||
|
||||
def _rebuild(self, project: str | None = None) -> None:
|
||||
"""The changed folder's index, and the top one, which links each
|
||||
project's. Links are pages from the vault's root, as SilverBullet
|
||||
names them."""
|
||||
if project is not None:
|
||||
self._write_index(
|
||||
self._where(project), f"# Memory: {project}", self._lines(project)
|
||||
)
|
||||
lines = self._lines(None)
|
||||
projects = self._projects()
|
||||
if projects:
|
||||
lines += ["", "Projects:"] + [
|
||||
f"- [[{self._where(p)}/{INDEX}]] — notes about {p}" for p in projects
|
||||
]
|
||||
self._write_index(self.folder, "# Memory", lines)
|
||||
|
||||
def _lines(self, project: str | None) -> list[str]:
|
||||
return [
|
||||
f"- [[{page}]] — {about}"
|
||||
for page, about in self._entries(self._where(project))
|
||||
]
|
||||
|
||||
def _write_index(self, where: str, title: str, lines: list[str]) -> None:
|
||||
body = f"{title}\n\nOne note per fact; kept by the harness.\n\n" + (
|
||||
"\n".join(lines) or "Nothing yet."
|
||||
)
|
||||
self._vault.write(
|
||||
f"{where}/{INDEX}.md",
|
||||
body + "\n",
|
||||
provenance={"source": "the harness, after a change"},
|
||||
)
|
||||
|
||||
def snapshot(self, project: str | None = None) -> Snapshot:
|
||||
"""The general index, and ``project``'s when it has notes, for the
|
||||
transcript; the general one is made first if it's missing."""
|
||||
paths = [f"{self.folder}/{INDEX}.md"]
|
||||
if project is not None and project in self._projects():
|
||||
paths.append(f"{self._where(project)}/{INDEX}.md")
|
||||
bodies = []
|
||||
for path in paths:
|
||||
try:
|
||||
text = self._vault.read(path)
|
||||
except VaultError:
|
||||
self._rebuild(None if path == paths[0] else project)
|
||||
text = self._vault.read(path)
|
||||
bodies.append(split_frontmatter(text)[1].strip())
|
||||
joined = "\n\n".join(bodies)
|
||||
sha = hashlib.sha256(joined.encode()).hexdigest()[:12]
|
||||
return Snapshot(tuple(paths), joined, sha)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Structured operational logging (architecture L12).
|
||||
|
||||
One JSON object per line to stderr (journald captures it when running as a
|
||||
service). Every event carries the fixed envelope: ts, agent, event, task_id,
|
||||
cause. Logs are diagnostic, never load-bearing — nothing may read them back
|
||||
to derive state.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, TextIO
|
||||
|
||||
|
||||
class OpLog:
|
||||
def __init__(self, agent: str, stream: TextIO | None = None) -> None:
|
||||
self.agent = agent
|
||||
# Correlation ID for the enclosing unit of work; the orchestrator sets
|
||||
# it when a unit starts and clears it when the unit ends (L12).
|
||||
self.task_id: str | None = None
|
||||
self._stream = stream if stream is not None else sys.stderr
|
||||
|
||||
def emit(self, event: str, *, cause: str, **fields: Any) -> None:
|
||||
record: dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(),
|
||||
"agent": self.agent,
|
||||
"event": event,
|
||||
"task_id": self.task_id,
|
||||
"cause": cause,
|
||||
**fields,
|
||||
}
|
||||
self._stream.write(json.dumps(record) + "\n")
|
||||
self._stream.flush()
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Check a model's declared context window against what its server does.
|
||||
|
||||
LiteLLM's ``max_input_tokens`` for a local model is only a declaration; the
|
||||
real limit is whatever the model server allocates. This sends one prompt
|
||||
half again as long as the declared window and reads back what the server did
|
||||
with it:
|
||||
|
||||
- **It refused.** Over-long prompts fail loudly, which is the safe behaviour.
|
||||
- **It cut the prompt.** The reported prompt size is where it cut, which is
|
||||
the server's real window. Ollama does this, silently.
|
||||
- **It took the whole prompt.** The server allows more than is declared, so
|
||||
the declaration is conservative.
|
||||
|
||||
Every answer is checked for which model gave it. A proxy that falls back to
|
||||
another model on an error returns a normal-looking answer under that model's
|
||||
name, and once made this probe report a refusing server as roomy.
|
||||
|
||||
Run it with ``scripts/probe_context.py``. It spends one small and one large
|
||||
request on the model, so it refuses windows above a ceiling unless told
|
||||
otherwise: commercial models' limits are already known to LiteLLM.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.catalog import ModelFacts
|
||||
|
||||
# Of a probe's size, how much must come back for it to count as uncut. A
|
||||
# chat template adds a little, and a tokenizer counts a little differently.
|
||||
_UNCUT = 0.9
|
||||
# How close the server's cut must be to the declaration to count as a match.
|
||||
_MATCH = 0.02
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeResult:
|
||||
verdict: str # "matches", "declared_too_high", "declared_conservative", ...
|
||||
message: str
|
||||
server_window: int | None = None
|
||||
|
||||
|
||||
def interpret(
|
||||
declared: int, sent: int, reported: int | None, error: str | None
|
||||
) -> ProbeResult:
|
||||
"""What one over-long probe says about the server's window.
|
||||
|
||||
``sent`` is roughly how many tokens the probe held; ``reported`` is the
|
||||
prompt size the server reported, if it answered; ``error`` its error
|
||||
text, if it refused.
|
||||
"""
|
||||
if error is not None:
|
||||
return ProbeResult(
|
||||
"refuses",
|
||||
f"the server refused a ~{sent}-token prompt ({error[:200]}). Over-long "
|
||||
"prompts fail loudly here, which is safe; the declared window of "
|
||||
f"{declared} was not contradicted.",
|
||||
)
|
||||
assert reported is not None
|
||||
if reported >= sent * _UNCUT:
|
||||
return ProbeResult(
|
||||
"declared_conservative",
|
||||
f"the server took the whole ~{sent}-token prompt ({reported} reported), "
|
||||
f"more than the declared {declared}. The declaration is safe; raise it "
|
||||
"if you want the room.",
|
||||
server_window=None,
|
||||
)
|
||||
if abs(reported - declared) <= declared * _MATCH:
|
||||
return ProbeResult(
|
||||
"matches",
|
||||
f"the server cut the prompt at {reported} tokens, matching the declared "
|
||||
f"{declared}.",
|
||||
server_window=reported,
|
||||
)
|
||||
if reported < declared:
|
||||
return ProbeResult(
|
||||
"declared_too_high",
|
||||
f"the server cut the prompt at {reported} tokens, but LiteLLM declares "
|
||||
f"{declared}. Prompts between the two would be silently cut: set "
|
||||
f"max_input_tokens to {reported}.",
|
||||
server_window=reported,
|
||||
)
|
||||
return ProbeResult(
|
||||
"declared_conservative",
|
||||
f"the server cut the prompt at {reported} tokens, above the declared "
|
||||
f"{declared}. The declaration is safe; it could be raised to {reported}.",
|
||||
server_window=reported,
|
||||
)
|
||||
|
||||
|
||||
def _prompt(words: int) -> str:
|
||||
return " ".join(f"item{i}" for i in range(words)) + "\n\nReply with just: ok"
|
||||
|
||||
|
||||
class Fallback(Exception):
|
||||
def __init__(self, answered_by: str) -> None:
|
||||
self.answered_by = answered_by
|
||||
|
||||
|
||||
def _send(
|
||||
http: httpx.Client, model: str, words: int, backing: str
|
||||
) -> tuple[int | None, str | None]:
|
||||
response = http.post(
|
||||
"/chat/completions",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": _prompt(words)}],
|
||||
"max_tokens": 5,
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None, f"{response.status_code}: {response.text}"
|
||||
data = response.json()
|
||||
answered = str(data.get("model") or "")
|
||||
facts = ModelFacts(alias=model, backing=backing, provider="", supports_tools=None)
|
||||
if not facts.answered(answered):
|
||||
raise Fallback(answered)
|
||||
usage = data.get("usage") or {}
|
||||
return int(usage.get("prompt_tokens") or 0), None
|
||||
|
||||
|
||||
def probe(
|
||||
http: httpx.Client, model: str, declared: int, backing: str = ""
|
||||
) -> ProbeResult:
|
||||
"""Measure how many tokens a word of the probe costs on this model, then
|
||||
send a prompt half again as long as the declared window."""
|
||||
calibration_words = 200
|
||||
try:
|
||||
tokens, error = _send(http, model, calibration_words, backing)
|
||||
if error is not None or not tokens:
|
||||
return ProbeResult("failed", f"a short calibration request failed: {error}")
|
||||
per_word = tokens / calibration_words
|
||||
sent = int(declared * 1.5)
|
||||
reported, error = _send(http, model, int(sent / per_word), backing)
|
||||
except Fallback as fallback:
|
||||
return ProbeResult(
|
||||
"fallback",
|
||||
f"{fallback.answered_by} answered instead of {model}: the proxy fell "
|
||||
"back to another model, so nothing here describes this one. Remove "
|
||||
"the fallback, or probe the server directly.",
|
||||
)
|
||||
return interpret(declared, sent, reported, error)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Boot-time reconciliation (architecture L8): turn an ambiguous crash into a safe,
|
||||
known state.
|
||||
|
||||
Every pending intent found at startup is checked against external truth by a
|
||||
registered reconciler — an observable check (query Gitea, inspect the tree),
|
||||
never model self-assessment. Anything unverifiable gates to AWAIT_INPUT;
|
||||
the agent never blind-resumes.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from nisse_dev.state import Intent, StateStore
|
||||
|
||||
# Returns True if the intent's external effect verifiably happened (do not
|
||||
# repeat it), False if it verifiably never started (safe to discard), or
|
||||
# None if it cannot be verified.
|
||||
Reconciler = Callable[[Intent], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootReport:
|
||||
action: Literal["resume", "await_input"]
|
||||
notes: list[str]
|
||||
|
||||
|
||||
def reconcile_on_boot(
|
||||
store: StateStore, reconcilers: Mapping[str, Reconciler]
|
||||
) -> BootReport:
|
||||
notes: list[str] = []
|
||||
unresolved = False
|
||||
for intent in store.pending_intents():
|
||||
label = f"intent {intent.id} ({intent.kind})"
|
||||
reconciler = reconcilers.get(intent.kind)
|
||||
if reconciler is None:
|
||||
unresolved = True
|
||||
notes.append(f"{label}: no reconciler registered")
|
||||
continue
|
||||
verdict = reconciler(intent)
|
||||
if verdict is True:
|
||||
store.commit_intent(
|
||||
intent.id, cause="reconciliation verdict: effect verified"
|
||||
)
|
||||
notes.append(f"{label}: external effect verified, marked committed")
|
||||
elif verdict is False:
|
||||
store.abort_intent(
|
||||
intent.id, cause="reconciliation verdict: never took effect"
|
||||
)
|
||||
notes.append(f"{label}: never took effect, marked aborted")
|
||||
else:
|
||||
unresolved = True
|
||||
notes.append(f"{label}: unverifiable")
|
||||
report = BootReport(
|
||||
action="await_input" if unresolved else "resume",
|
||||
notes=notes,
|
||||
)
|
||||
store.oplog.emit(
|
||||
"boot.reconcile", cause="startup", action=report.action, verdicts=notes
|
||||
)
|
||||
return report
|
||||
@@ -1,158 +0,0 @@
|
||||
"""L7 renderer: provider request bodies are built from the canonical log at
|
||||
call time, never stored provider-shaped.
|
||||
|
||||
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
|
||||
speaks — the executor role's path (L9). The Anthropic passthrough adapter
|
||||
arrives with delegations (L9). Token budgeting and prompt-cache breakpoints
|
||||
are render-time concerns and are never stored (L7); neither exists yet in
|
||||
this skeleton.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
def effective_events(stored: list[StoredEvent]) -> list[StoredEvent]:
|
||||
"""Apply compactions: events whose ids fall in a compaction's replaced
|
||||
range are dropped; the compaction event itself stays as their summary."""
|
||||
replaced: list[tuple[int, int]] = [
|
||||
(e.event.replaces[0], e.event.replaces[1])
|
||||
for e in stored
|
||||
if isinstance(e.event, Compaction)
|
||||
]
|
||||
|
||||
def is_replaced(event_id: int) -> bool:
|
||||
return any(first <= event_id <= last for first, last in replaced)
|
||||
|
||||
return [
|
||||
e for e in stored if isinstance(e.event, Compaction) or not is_replaced(e.id)
|
||||
]
|
||||
|
||||
|
||||
class OpenAIChatRenderer:
|
||||
"""Mappings for orchestrator-side events (gates, plans, delegations,
|
||||
compaction summaries) are provisional; they get tuned when the executor
|
||||
loop (step 5) and delegations (step 7) consume them for real."""
|
||||
|
||||
def render(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
if system is not None:
|
||||
messages.append({"role": "system", "content": system})
|
||||
kept = effective_events(events)
|
||||
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
||||
for stored in kept:
|
||||
_append_merged(messages, self._message_for(stored.event))
|
||||
# Providers reject a tool call with no result following it. One
|
||||
# can legitimately be missing: a crash between the call and its
|
||||
# result (L8), or a turn-ending gate tool. Render a placeholder so
|
||||
# the transcript stays replayable rather than poisoned.
|
||||
if isinstance(stored.event, ToolCall) and (
|
||||
stored.event.call_id not in answered
|
||||
):
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": stored.event.call_id,
|
||||
"content": "[no result recorded]",
|
||||
}
|
||||
)
|
||||
body: dict[str, Any] = {"model": model, "messages": messages}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
return body
|
||||
|
||||
def _message_for(self, event: object) -> dict[str, Any]:
|
||||
match event:
|
||||
case UserMessage(text=text):
|
||||
return {"role": "user", "content": text}
|
||||
case AssistantText(text=text):
|
||||
return {"role": "assistant", "content": text}
|
||||
case ToolCall(call_id=call_id, tool=tool, arguments=arguments):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool,
|
||||
"arguments": json.dumps(arguments),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
case ToolResult(call_id=call_id, result=result):
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": result,
|
||||
}
|
||||
case PlanSubmitted(plan=plan):
|
||||
return {"role": "assistant", "content": plan}
|
||||
case GateOpened(gate=gate, reason=reason):
|
||||
return {"role": "user", "content": f"[gate {gate} opened: {reason}]"}
|
||||
case DelegationStarted(brief=brief, model=model):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": f"[delegation started ({model}): {brief}]",
|
||||
}
|
||||
case DelegationCompleted(handback=handback):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": f"[delegation handback]\n{handback}",
|
||||
}
|
||||
case Fact(text=text):
|
||||
return {"role": "user", "content": f"[orchestrator] {text}"}
|
||||
case Compaction(summary=summary):
|
||||
return {
|
||||
"role": "user",
|
||||
"content": f"[summary of earlier conversation]\n{summary}",
|
||||
}
|
||||
case _:
|
||||
raise ValueError(f"unrenderable event: {event!r}")
|
||||
|
||||
|
||||
def _append_merged(messages: list[dict[str, Any]], message: dict[str, Any]) -> None:
|
||||
"""Append, folding into the previous message when both are the same user
|
||||
or assistant role.
|
||||
|
||||
One model turn is often several events (some text, then one or more tool
|
||||
calls), and the OpenAI format carries a turn as one assistant message
|
||||
with a tool_calls list. Strict chat templates enforce that: Devstral's
|
||||
(Mistral) rejected a turn rendered as a text message then a tool-call
|
||||
message with a 500, failing runs whenever the model spoke before acting."""
|
||||
previous = messages[-1] if messages else None
|
||||
role = message["role"]
|
||||
if (
|
||||
previous is None
|
||||
or previous["role"] != role
|
||||
or role not in ("user", "assistant")
|
||||
):
|
||||
messages.append(message)
|
||||
return
|
||||
texts = [t for t in (previous.get("content"), message.get("content")) if t]
|
||||
previous["content"] = "\n\n".join(texts) if texts else None
|
||||
calls = previous.get("tool_calls", []) + message.get("tool_calls", [])
|
||||
if calls:
|
||||
previous["tool_calls"] = calls
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Anthropic Messages renderer (architecture L7, L9).
|
||||
|
||||
The second adapter, and the reason L9 puts normalisation in the renderer
|
||||
rather than on the wire: the two vendors disagree *structurally*, not
|
||||
cosmetically.
|
||||
|
||||
| | OpenAI-compatible | Anthropic Messages |
|
||||
|---|---|---|
|
||||
| system prompt | a message with role `system` | a top-level `system` field |
|
||||
| tool call | `tool_calls` array on the message | a `tool_use` content block |
|
||||
| tool result | a message with role `tool` | a `tool_result` block in a **user** message |
|
||||
| tool schema | `function.parameters` | `input_schema` |
|
||||
|
||||
A middlebox that normalised between these would be lossy exactly where a
|
||||
long-running delegation lives, which is why delegations take the passthrough
|
||||
route and this renderer builds the vendor-native body.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.render import effective_events
|
||||
|
||||
DEFAULT_MAX_TOKENS = 4096
|
||||
NO_RESULT_PLACEHOLDER = "[no result recorded]"
|
||||
|
||||
|
||||
def to_anthropic_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""Convert the OpenAI-shaped manifests the tool modules already define,
|
||||
so a tool is declared once and rendered per vendor."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools or []:
|
||||
fn = tool.get("function", tool)
|
||||
converted.append(
|
||||
{
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get(
|
||||
"parameters", {"type": "object", "properties": {}}
|
||||
),
|
||||
}
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
class AnthropicMessagesRenderer:
|
||||
def render(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int = DEFAULT_MAX_TOKENS,
|
||||
) -> dict[str, Any]:
|
||||
kept = effective_events(events)
|
||||
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
||||
blocks: list[tuple[str, dict[str, Any]]] = []
|
||||
for stored in kept:
|
||||
blocks.extend(self._blocks_for(stored.event))
|
||||
# A dangling tool_use is rejected outright by this API — more
|
||||
# strictly than the OpenAI shape — so a crash between a call and
|
||||
# its result must not poison the transcript (L8).
|
||||
if isinstance(stored.event, ToolCall) and (
|
||||
stored.event.call_id not in answered
|
||||
):
|
||||
blocks.append(
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": stored.event.call_id,
|
||||
"content": NO_RESULT_PLACEHOLDER,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": _coalesce(blocks),
|
||||
}
|
||||
if system is not None:
|
||||
body["system"] = system
|
||||
if tools:
|
||||
body["tools"] = to_anthropic_tools(tools)
|
||||
return body
|
||||
|
||||
def _blocks_for(self, event: object) -> list[tuple[str, dict[str, Any]]]:
|
||||
match event:
|
||||
case UserMessage(text=text):
|
||||
return [("user", {"type": "text", "text": text})]
|
||||
case AssistantText(text=text):
|
||||
return [("assistant", {"type": "text", "text": text})]
|
||||
case ToolCall(call_id=call_id, tool=tool, arguments=arguments):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": tool,
|
||||
"input": arguments,
|
||||
},
|
||||
)
|
||||
]
|
||||
case ToolResult(call_id=call_id, result=result):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": call_id,
|
||||
"content": result,
|
||||
},
|
||||
)
|
||||
]
|
||||
case PlanSubmitted(plan=plan):
|
||||
return [("assistant", {"type": "text", "text": plan})]
|
||||
case GateOpened(gate=gate, reason=reason):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{"type": "text", "text": f"[gate {gate} opened: {reason}]"},
|
||||
)
|
||||
]
|
||||
case DelegationStarted(brief=brief, model=model):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"[delegation started ({model}): {brief}]",
|
||||
},
|
||||
)
|
||||
]
|
||||
case DelegationCompleted(handback=handback):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{"type": "text", "text": f"[delegation handback]\n{handback}"},
|
||||
)
|
||||
]
|
||||
case Fact(text=text):
|
||||
return [("user", {"type": "text", "text": f"[orchestrator] {text}"})]
|
||||
case Compaction(summary=summary):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"[summary of earlier conversation]\n{summary}",
|
||||
},
|
||||
)
|
||||
]
|
||||
case _:
|
||||
raise ValueError(f"unrenderable event: {event!r}")
|
||||
|
||||
|
||||
def _coalesce(
|
||||
blocks: list[tuple[str, dict[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge adjacent blocks of the same role into one message.
|
||||
|
||||
The Messages API expects alternating roles, and a tool_result must sit in
|
||||
the user message directly after the assistant's tool_use — which happens
|
||||
naturally once same-role runs are merged.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for role, block in blocks:
|
||||
if messages and messages[-1]["role"] == role:
|
||||
messages[-1]["content"].append(block)
|
||||
else:
|
||||
messages.append({"role": role, "content": [block]})
|
||||
return messages
|
||||
@@ -1,42 +1,12 @@
|
||||
"""Durable state store (architecture L8).
|
||||
"""nisse-dev's state store (architecture L8): the shared one, with the dev
|
||||
agent's own roles, views and tables (build VM blocks, D3)."""
|
||||
|
||||
SQLite in WAL mode with ``synchronous=FULL``: every commit is fsynced, so a
|
||||
power cut mid-write can never produce torn state. Every state transition and
|
||||
intent-log write is a single immediately-committed statement — in-memory state
|
||||
that hasn't hit disk is assumed lost.
|
||||
|
||||
The intent log records a unit of work *before* it starts, so a restart knows
|
||||
what was in flight (see ``nisse_dev.reconcile`` for how pending intents are
|
||||
resolved against external truth on boot).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
CanonicalEvent,
|
||||
StoredEvent,
|
||||
UserMessage,
|
||||
event_type,
|
||||
from_payload,
|
||||
to_payload,
|
||||
)
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.views import project
|
||||
|
||||
CURRENT_SCHEMA_VERSION = 5
|
||||
|
||||
# Stores older than this predate work items (Development Plan M1.1). There was
|
||||
# no production data to carry over, so there is no migration from them: start
|
||||
# a fresh store (scripts/bringup.sh start sets the old one aside).
|
||||
OLDEST_SUPPORTED_VERSION = 4
|
||||
|
||||
Migration = Callable[[sqlite3.Connection], None]
|
||||
from nisse_core import state
|
||||
from nisse_core.state import Migration, utc_now
|
||||
|
||||
_VM_BLOCKS = """
|
||||
CREATE TABLE vm_blocks (
|
||||
@@ -53,46 +23,6 @@ def _add_vm_blocks(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(_VM_BLOCKS)
|
||||
|
||||
|
||||
# Maps version N to the migration that takes the schema from N to N+1.
|
||||
MIGRATIONS: Mapping[int, Migration] = {4: _add_vm_blocks}
|
||||
|
||||
# Who produced an event (L7): the operator, the orchestrator's own code, or a
|
||||
# model role (L9). planner and executor are the old loop's (until M2.8), kept
|
||||
# so its records still read.
|
||||
ACTORS = frozenset(
|
||||
{
|
||||
"operator",
|
||||
"orchestrator",
|
||||
"planner",
|
||||
"executor",
|
||||
"driver",
|
||||
"helper",
|
||||
"expert",
|
||||
"classifier",
|
||||
"toolchain", # the toolchain step (D3): the driver's model, apart
|
||||
"memory", # the memory pass at an item's close (L6.4), apart too
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateSchemaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkItem:
|
||||
id: str
|
||||
scope: str | None
|
||||
title: str
|
||||
status: str # open | closed
|
||||
outcome: str | None # completed | abandoned, once closed
|
||||
position: str # where the loop is for this item
|
||||
data: dict[str, Any]
|
||||
opened_at: str
|
||||
updated_at: str
|
||||
closed_at: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VMBlock:
|
||||
"""A build VM nothing may run on until it is recovered (D3): every
|
||||
@@ -105,298 +35,30 @@ class VMBlock:
|
||||
since: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Intent:
|
||||
id: int
|
||||
kind: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
created_at: str
|
||||
resolved_at: str | None
|
||||
work_item: str | None = None
|
||||
|
||||
|
||||
_SCHEMA = [
|
||||
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
"""
|
||||
CREATE TABLE work_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open', 'closed')),
|
||||
outcome TEXT,
|
||||
position TEXT NOT NULL,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
opened_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
closed_at TEXT
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE agent (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
active_item TEXT REFERENCES work_items(id),
|
||||
idle_from_event INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE intent_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
work_item TEXT
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE canonical_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
work_item TEXT,
|
||||
actor TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX canonical_events_by_item ON canonical_events (work_item, id)",
|
||||
"""
|
||||
CREATE TABLE eval_outcomes (
|
||||
event_id INTEGER PRIMARY KEY,
|
||||
verdict TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
marked_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
_VM_BLOCKS,
|
||||
]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
schema_version: int = CURRENT_SCHEMA_VERSION,
|
||||
migrations: Mapping[int, Migration] = MIGRATIONS,
|
||||
oplog: OpLog | None = None,
|
||||
) -> None:
|
||||
# Log emission is owned by the store (L12): the same code path that
|
||||
# performs a durable write emits its log line, right after commit.
|
||||
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
|
||||
self._conn = sqlite3.connect(path, isolation_level=None)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=FULL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._target_version = schema_version
|
||||
self.closed = False
|
||||
self._init_or_migrate(migrations)
|
||||
|
||||
def _init_or_migrate(self, migrations: Mapping[int, Migration]) -> None:
|
||||
fresh = (
|
||||
self._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='meta'"
|
||||
).fetchone()
|
||||
is None
|
||||
)
|
||||
if fresh:
|
||||
self._in_transaction(self._create_schema)
|
||||
return
|
||||
stored = self.schema_version
|
||||
if stored < OLDEST_SUPPORTED_VERSION <= self._target_version:
|
||||
raise StateSchemaError(
|
||||
f"state store schema version {stored} predates work items "
|
||||
f"(version {OLDEST_SUPPORTED_VERSION}); start a fresh store "
|
||||
"(scripts/bringup.sh start sets the old one aside)"
|
||||
)
|
||||
if stored > self._target_version:
|
||||
raise StateSchemaError(
|
||||
f"state schema version {stored} is newer than this code "
|
||||
f"supports ({self._target_version}); refusing to open"
|
||||
)
|
||||
for version in range(stored, self._target_version):
|
||||
migrate = migrations.get(version)
|
||||
if migrate is None:
|
||||
raise StateSchemaError(
|
||||
f"no migration registered from schema version {version}"
|
||||
)
|
||||
self._in_transaction(lambda m=migrate, v=version: self._apply(m, v))
|
||||
self.oplog.emit(
|
||||
"schema.migrate",
|
||||
cause="startup",
|
||||
from_version=version,
|
||||
to_version=version + 1,
|
||||
)
|
||||
|
||||
def _create_schema(self) -> None:
|
||||
for statement in _SCHEMA:
|
||||
self._conn.execute(statement)
|
||||
self._conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
(str(self._target_version),),
|
||||
)
|
||||
|
||||
def _apply(self, migrate: Migration, from_version: int) -> None:
|
||||
migrate(self._conn)
|
||||
self._conn.execute(
|
||||
"UPDATE meta SET value = ? WHERE key = 'schema_version'",
|
||||
(str(from_version + 1),),
|
||||
)
|
||||
|
||||
def _in_transaction(self, work: Callable[[], None]) -> None:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
work()
|
||||
except BaseException:
|
||||
self._conn.execute("ROLLBACK")
|
||||
raise
|
||||
self._conn.execute("COMMIT")
|
||||
|
||||
@property
|
||||
def schema_version(self) -> int:
|
||||
row = self._conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'schema_version'"
|
||||
).fetchone()
|
||||
return int(row["value"])
|
||||
|
||||
# --- work items (L1) ---------------------------------------------------
|
||||
|
||||
def _ensure_agent_row(self) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT INTO agent (id, active_item, updated_at) VALUES (1, NULL, ?) "
|
||||
"ON CONFLICT(id) DO NOTHING",
|
||||
(_now(),),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _item(row: sqlite3.Row) -> WorkItem:
|
||||
return WorkItem(
|
||||
id=row["id"],
|
||||
scope=row["scope"],
|
||||
title=row["title"],
|
||||
status=row["status"],
|
||||
outcome=row["outcome"],
|
||||
position=row["position"],
|
||||
data=json.loads(row["data"]),
|
||||
opened_at=row["opened_at"],
|
||||
updated_at=row["updated_at"],
|
||||
closed_at=row["closed_at"],
|
||||
)
|
||||
|
||||
def work_item(self, item_id: str) -> WorkItem | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM work_items WHERE id = ?", (item_id,)
|
||||
).fetchone()
|
||||
return None if row is None else self._item(row)
|
||||
|
||||
def work_items(self, status: str | None = None) -> list[WorkItem]:
|
||||
query = "SELECT * FROM work_items"
|
||||
args: tuple[Any, ...] = ()
|
||||
if status is not None:
|
||||
query += " WHERE status = ?"
|
||||
args = (status,)
|
||||
rows = self._conn.execute(query + " ORDER BY opened_at", args).fetchall()
|
||||
return [self._item(row) for row in rows]
|
||||
|
||||
def active_item(self) -> WorkItem | None:
|
||||
row = self._conn.execute(
|
||||
"SELECT w.* FROM agent a JOIN work_items w ON w.id = a.active_item "
|
||||
"WHERE a.id = 1"
|
||||
).fetchone()
|
||||
return None if row is None else self._item(row)
|
||||
|
||||
def open_work_item(
|
||||
self,
|
||||
item_id: str,
|
||||
*,
|
||||
title: str,
|
||||
scope: str | None = None,
|
||||
position: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
cause: str,
|
||||
) -> WorkItem:
|
||||
now = _now()
|
||||
self._conn.execute(
|
||||
"INSERT INTO work_items (id, scope, title, status, position, data, "
|
||||
"opened_at, updated_at) VALUES (?, ?, ?, 'open', ?, ?, ?, ?)",
|
||||
(item_id, scope, title, position, json.dumps(data or {}), now, now),
|
||||
)
|
||||
self.oplog.emit("work_item.open", cause=cause, work_item=item_id, scope=scope)
|
||||
item = self.work_item(item_id)
|
||||
assert item is not None
|
||||
return item
|
||||
|
||||
def set_active(self, item_id: str | None, *, cause: str) -> None:
|
||||
"""Point the agent at a work item, or at none (idle). Going idle
|
||||
records where in the log idleness began, so messages sent while idle
|
||||
can be carried into the next work item (carry_idle_messages)."""
|
||||
self._ensure_agent_row()
|
||||
previous = self._conn.execute(
|
||||
"SELECT active_item FROM agent WHERE id = 1"
|
||||
).fetchone()["active_item"]
|
||||
if item_id is None:
|
||||
last = self._conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) AS last FROM canonical_events"
|
||||
).fetchone()["last"]
|
||||
self._conn.execute(
|
||||
"UPDATE agent SET active_item = NULL, idle_from_event = ?, "
|
||||
"updated_at = ? WHERE id = 1",
|
||||
(last, _now()),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"UPDATE agent SET active_item = ?, updated_at = ? WHERE id = 1",
|
||||
(item_id, _now()),
|
||||
)
|
||||
if previous != item_id:
|
||||
self.oplog.emit(
|
||||
"work_item.switch",
|
||||
cause=cause,
|
||||
**{"from": previous, "to": item_id},
|
||||
)
|
||||
|
||||
def update_work_item(
|
||||
self,
|
||||
item_id: str,
|
||||
*,
|
||||
position: str,
|
||||
data: dict[str, Any],
|
||||
cause: str,
|
||||
) -> None:
|
||||
"""Record a work item's new position and data; the full data is
|
||||
passed every time (no partial updates)."""
|
||||
previous = self.work_item(item_id)
|
||||
if previous is None:
|
||||
raise KeyError(f"no work item {item_id!r}")
|
||||
self._conn.execute(
|
||||
"UPDATE work_items SET position = ?, data = ?, updated_at = ? WHERE id = ?",
|
||||
(position, json.dumps(data), _now(), item_id),
|
||||
)
|
||||
self.oplog.emit(
|
||||
"state.transition",
|
||||
cause=cause,
|
||||
**{"from": previous.position, "to": position, "project": previous.scope},
|
||||
)
|
||||
|
||||
def close_work_item(self, item_id: str, *, outcome: str, cause: str) -> None:
|
||||
now = _now()
|
||||
row = self._conn.execute(
|
||||
"UPDATE work_items SET status = 'closed', outcome = ?, closed_at = ?, "
|
||||
"updated_at = ? WHERE id = ? AND status = 'open' RETURNING id",
|
||||
(outcome, now, now, item_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"no open work item {item_id!r}")
|
||||
self.oplog.emit(
|
||||
"work_item.close", cause=cause, work_item=item_id, outcome=outcome
|
||||
)
|
||||
class StateStore(state.StateStore):
|
||||
AGENT = "nisse-dev"
|
||||
SCHEMA_VERSION = 5
|
||||
SCHEMA = (_VM_BLOCKS,)
|
||||
MIGRATIONS: Mapping[int, Migration] = {4: _add_vm_blocks}
|
||||
# planner and executor are the old loop's (until M2.8), kept so its
|
||||
# records still read.
|
||||
ACTORS = state.ACTORS | {
|
||||
"planner",
|
||||
"executor",
|
||||
"toolchain", # the toolchain step (D3): the driver's model, apart
|
||||
"memory", # the memory pass at an item's close (L6.4), apart too
|
||||
}
|
||||
# What each role is shown of the others' work (``nisse_core.views``).
|
||||
SHOWN: Mapping[str, Mapping[str, str]] = {
|
||||
"driver": {"helper": "report"},
|
||||
"helper": {"driver": "narrate"},
|
||||
# The toolchain step works in its own transcript: the driver sees
|
||||
# only its report (a tool result, or facts), the step the driver's
|
||||
# work.
|
||||
"toolchain": {"driver": "narrate"},
|
||||
# The memory pass at an item's close (L6.4): the item as it went.
|
||||
"memory": {"driver": "narrate"},
|
||||
}
|
||||
|
||||
# --- build VMs (D3) ----------------------------------------------------
|
||||
|
||||
@@ -407,7 +69,7 @@ class StateStore:
|
||||
row = self._conn.execute(
|
||||
"INSERT INTO vm_blocks (host, cause, reason, work_item, since) "
|
||||
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(host) DO NOTHING RETURNING host",
|
||||
(host, cause, reason, work_item, _now()),
|
||||
(host, cause, reason, work_item, utc_now()),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
self.oplog.emit("vm.block", cause=cause, host=host, work_item=work_item)
|
||||
@@ -434,206 +96,3 @@ class StateStore:
|
||||
def vm_blocks(self) -> dict[str, VMBlock]:
|
||||
rows = self._conn.execute("SELECT * FROM vm_blocks").fetchall()
|
||||
return {row["host"]: VMBlock(**dict(row)) for row in rows}
|
||||
|
||||
def carry_idle_messages(self, item_id: str) -> int:
|
||||
"""Copy what the operator said while the agent was idle into a work
|
||||
item that has just opened (D1: messages sent before `use` are kept
|
||||
for it). Copied as new events, with the original's ID in the source,
|
||||
never by reassigning the originals: the log is never edited (L7)."""
|
||||
self._ensure_agent_row()
|
||||
since = self._conn.execute(
|
||||
"SELECT idle_from_event FROM agent WHERE id = 1"
|
||||
).fetchone()["idle_from_event"]
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM canonical_events WHERE work_item IS NULL "
|
||||
"AND actor = 'operator' AND type = 'user_message' AND id > ? ORDER BY id",
|
||||
(since,),
|
||||
).fetchall()
|
||||
log = self.transcript(item_id, "operator")
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload"])
|
||||
log.append(
|
||||
UserMessage(
|
||||
text=payload["text"],
|
||||
source=f"{payload.get('source', '')} (sent while idle, #{row['id']})",
|
||||
)
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
# --- intents (L8) ------------------------------------------------------
|
||||
|
||||
def begin_intent(
|
||||
self,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
cause: str,
|
||||
work_item: str | None = None,
|
||||
) -> int:
|
||||
"""Durably record that a unit of work is about to start, owned by
|
||||
the work item it belongs to (L1)."""
|
||||
cursor = self._conn.execute(
|
||||
"INSERT INTO intent_log (kind, payload, created_at, work_item) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(kind, json.dumps(payload), _now(), work_item),
|
||||
)
|
||||
assert cursor.lastrowid is not None
|
||||
self.oplog.emit(
|
||||
"intent.begin", cause=cause, intent_id=cursor.lastrowid, kind=kind
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def commit_intent(self, intent_id: int, *, cause: str) -> None:
|
||||
self._resolve_intent(intent_id, "committed", "intent.commit", cause)
|
||||
|
||||
def abort_intent(self, intent_id: int, *, cause: str) -> None:
|
||||
self._resolve_intent(intent_id, "aborted", "intent.abort", cause)
|
||||
|
||||
def _resolve_intent(
|
||||
self, intent_id: int, status: str, event: str, cause: str
|
||||
) -> None:
|
||||
row = self._conn.execute(
|
||||
"UPDATE intent_log SET status = ?, resolved_at = ? "
|
||||
"WHERE id = ? AND status = 'pending' RETURNING kind",
|
||||
(status, _now(), intent_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"no pending intent with id {intent_id}")
|
||||
self.oplog.emit(event, cause=cause, intent_id=intent_id, kind=row["kind"])
|
||||
|
||||
def pending_intents(self) -> list[Intent]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM intent_log WHERE status = 'pending' ORDER BY id"
|
||||
).fetchall()
|
||||
return [
|
||||
Intent(
|
||||
id=row["id"],
|
||||
kind=row["kind"],
|
||||
payload=json.loads(row["payload"]),
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
resolved_at=row["resolved_at"],
|
||||
work_item=row["work_item"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# --- the canonical log (L7) -------------------------------------------
|
||||
|
||||
def transcript(self, work_item: str | None, actor: str) -> "Transcript":
|
||||
"""The log as one role sees it for one work item: what it reads is
|
||||
that item's events only, and what it writes carries the item and the
|
||||
actor. None is the agent-level scope (L6)."""
|
||||
if actor not in ACTORS:
|
||||
raise ValueError(f"unknown actor {actor!r}")
|
||||
return Transcript(self, work_item, actor)
|
||||
|
||||
def all_events(self, after: int = 0) -> list[StoredEvent]:
|
||||
"""Every event across work items, for the few readers whose job
|
||||
spans them (evaluation capture, dumps). Model calls never use this."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM canonical_events WHERE id > ? ORDER BY id", (after,)
|
||||
).fetchall()
|
||||
return [_stored(row) for row in rows]
|
||||
|
||||
def _append(self, event: CanonicalEvent, work_item: str | None, actor: str) -> int:
|
||||
cursor = self._conn.execute(
|
||||
"INSERT INTO canonical_events (ts, type, payload, work_item, actor) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
_now(),
|
||||
event_type(event),
|
||||
json.dumps(to_payload(event)),
|
||||
work_item,
|
||||
actor,
|
||||
),
|
||||
)
|
||||
assert cursor.lastrowid is not None
|
||||
return cursor.lastrowid
|
||||
|
||||
def _read(self, work_item: str | None, after: int) -> list[StoredEvent]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM canonical_events WHERE work_item IS ? AND id > ? "
|
||||
"ORDER BY id",
|
||||
(work_item, after),
|
||||
).fetchall()
|
||||
return [_stored(row) for row in rows]
|
||||
|
||||
def mark_outcome(self, event_id: int, verdict: str, note: str = "") -> None:
|
||||
"""Record whether a delegation or consult was good (L9).
|
||||
|
||||
Kept out of the canonical log on purpose: the log is an append-only
|
||||
record of what happened, and a verdict is a later judgement about it
|
||||
that may be revised.
|
||||
"""
|
||||
self._conn.execute(
|
||||
"INSERT INTO eval_outcomes (event_id, verdict, note, marked_at) "
|
||||
"VALUES (?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET "
|
||||
"verdict = excluded.verdict, note = excluded.note, "
|
||||
"marked_at = excluded.marked_at",
|
||||
(event_id, verdict, note, _now()),
|
||||
)
|
||||
|
||||
def outcomes(self) -> dict[int, tuple[str, str]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT event_id, verdict, note FROM eval_outcomes"
|
||||
).fetchall()
|
||||
return {r["event_id"]: (r["verdict"], r["note"]) for r in rows}
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _stored(row: sqlite3.Row) -> StoredEvent:
|
||||
return StoredEvent(
|
||||
id=row["id"],
|
||||
ts=row["ts"],
|
||||
event=from_payload(row["type"], json.loads(row["payload"])),
|
||||
work_item=row["work_item"],
|
||||
actor=row["actor"],
|
||||
)
|
||||
|
||||
|
||||
class Transcript:
|
||||
"""One work item's log, as one actor reads and writes it (L6, L7).
|
||||
|
||||
Handed to each component per call by the orchestrator, which knows the
|
||||
active work item, so a component can neither read another item's events
|
||||
nor write without attribution.
|
||||
"""
|
||||
|
||||
def __init__(self, store: StateStore, work_item: str | None, actor: str) -> None:
|
||||
self._store = store
|
||||
self.work_item = work_item
|
||||
self.actor = actor
|
||||
|
||||
def append(self, event: CanonicalEvent) -> int:
|
||||
"""Committed and fsynced before return, like every durable write."""
|
||||
return self._store._append(event, self.work_item, self.actor)
|
||||
|
||||
def read(self, after: int = 0) -> list[StoredEvent]:
|
||||
"""The log as recorded: every role's events, for the record."""
|
||||
return self._store._read(self.work_item, after)
|
||||
|
||||
def view(self, after: int = 0) -> list[StoredEvent]:
|
||||
"""The log as this actor is shown it: what a model's context is
|
||||
built from (L6). See ``views``."""
|
||||
return project(self.read(after), self.actor)
|
||||
|
||||
def as_actor(self, actor: str) -> "Transcript":
|
||||
"""The same work item, written by another role: e.g. the executor
|
||||
handing a sub-task to the helper."""
|
||||
return self._store.transcript(self.work_item, actor)
|
||||
|
||||
def begin_intent(self, kind: str, payload: dict[str, Any], *, cause: str) -> int:
|
||||
"""An intent owned by this transcript's work item."""
|
||||
return self._store.begin_intent(
|
||||
kind, payload, cause=cause, work_item=self.work_item
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Test support: a throwaway sshd standing in for a tool VM (L13).
|
||||
|
||||
Runs the system's sshd as the current user, on a free local port, with a
|
||||
host key and client key made for the occasion. Tests then reach "the build
|
||||
VM" over real SSH, so the code path under test is the production path.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
|
||||
SSHD = "/usr/sbin/sshd"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _keygen(path: Path) -> None:
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(path)], check=True
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def local_sshd(root: Path) -> Iterator[SSHHost]:
|
||||
"""An SSHHost for a private sshd on 127.0.0.1, torn down afterwards."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
host_key, client_key = root / "host_key", root / "client_key"
|
||||
_keygen(host_key)
|
||||
_keygen(client_key)
|
||||
authorized = root / "authorized_keys"
|
||||
authorized.write_text(client_key.with_suffix(".pub").read_text())
|
||||
authorized.chmod(0o600)
|
||||
port = _free_port()
|
||||
config = root / "sshd_config"
|
||||
config.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"Port {port}",
|
||||
"ListenAddress 127.0.0.1",
|
||||
f"HostKey {host_key}",
|
||||
f"AuthorizedKeysFile {authorized}",
|
||||
f"PidFile {root / 'sshd.pid'}",
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"PubkeyAuthentication yes",
|
||||
"PermitRootLogin prohibit-password",
|
||||
"StrictModes no",
|
||||
"UsePAM no",
|
||||
"MaxSessions 50",
|
||||
"MaxStartups 50",
|
||||
# Stands in for a provisioned build VM: sessions see the test
|
||||
# runner's PATH, so its Python and tools are "installed".
|
||||
f"SetEnv PATH={os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
[SSHD, "-D", "-e", "-f", str(config)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
user = getpass.getuser()
|
||||
host = SSHHost(
|
||||
"127.0.0.1", key=client_key, state_dir=root / "client", user=user, port=port
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
"sshd exited: " + (process.stderr.read() or b"").decode()
|
||||
if process.stderr
|
||||
else "sshd exited"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("sshd did not start") from None
|
||||
time.sleep(0.05)
|
||||
yield host
|
||||
finally:
|
||||
host.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
@@ -20,8 +20,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.context import head_and_tail
|
||||
from nisse_dev.execution import HostUnavailable, Result, SSHHost
|
||||
from nisse_core.context import head_and_tail
|
||||
from nisse_core.execution import HostUnavailable, Result, SSHHost
|
||||
|
||||
# Tools that change or verify something. Reading a file or listing a
|
||||
# directory is a model looking around, not doing the work, so neither counts
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Transport interface (architecture L5): all human-to-agent conversation.
|
||||
|
||||
The orchestrator depends only on this interface; no platform SDK is imported
|
||||
anywhere outside a concrete implementation. MatrixTransport arrives as its own
|
||||
class + config with no orchestrator changes.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundMessage:
|
||||
sender: str
|
||||
# What commands are matched against: a leading mention of the agent,
|
||||
# which only says who the message is for, is already removed.
|
||||
text: str
|
||||
# The message as sent, when that differs from text; the record keeps it.
|
||||
original: str | None = None
|
||||
# The platform's ID for this message, and the root of the thread it was
|
||||
# sent in (None at top level). A work item's conversation is a thread
|
||||
# (L5), so this is how a reply finds its item.
|
||||
event_id: str = ""
|
||||
thread: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboundMessage:
|
||||
kind: str # "message" | "approval_request" | "question"
|
||||
text: str
|
||||
thread: str | None = None
|
||||
event_id: str = ""
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
def post(self, text: str, *, thread: str | None = None) -> str:
|
||||
"""Send a message, in a thread when one is given; returns its ID,
|
||||
which can root a new thread."""
|
||||
...
|
||||
|
||||
def send_message(self, text: str) -> None: ...
|
||||
|
||||
def send_approval_request(self, text: str) -> None: ...
|
||||
|
||||
def ask_question(self, text: str) -> None: ...
|
||||
|
||||
def on_message(self, callback: Callable[[InboundMessage], None]) -> None: ...
|
||||
|
||||
|
||||
class InMemoryTransport:
|
||||
"""Records outbound traffic and lets tests inject inbound messages."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbound: list[OutboundMessage] = []
|
||||
self._callback: Callable[[InboundMessage], None] | None = None
|
||||
self._next_id = 0
|
||||
|
||||
def _id(self) -> str:
|
||||
self._next_id += 1
|
||||
return f"$event{self._next_id}"
|
||||
|
||||
def post(self, text: str, *, thread: str | None = None) -> str:
|
||||
event_id = self._id()
|
||||
self.outbound.append(OutboundMessage("message", text, thread, event_id))
|
||||
return event_id
|
||||
|
||||
def send_message(self, text: str) -> None:
|
||||
self.outbound.append(OutboundMessage("message", text, None, self._id()))
|
||||
|
||||
def send_approval_request(self, text: str) -> None:
|
||||
self.outbound.append(
|
||||
OutboundMessage("approval_request", text, None, self._id())
|
||||
)
|
||||
|
||||
def ask_question(self, text: str) -> None:
|
||||
self.outbound.append(OutboundMessage("question", text, None, self._id()))
|
||||
|
||||
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
|
||||
self._callback = callback
|
||||
|
||||
def start(self) -> None:
|
||||
"""Nothing to connect to: the transport's lifecycle, for the agent."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Nothing to disconnect from."""
|
||||
|
||||
def receive(
|
||||
self,
|
||||
sender: str,
|
||||
text: str,
|
||||
original: str | None = None,
|
||||
*,
|
||||
thread: str | None = None,
|
||||
) -> str:
|
||||
"""Deliver a message as if the operator sent it; returns its ID."""
|
||||
assert self._callback is not None, "no message handler registered"
|
||||
event_id = self._id()
|
||||
self._callback(InboundMessage(sender, text, original, event_id, thread))
|
||||
return event_id
|
||||
@@ -1,262 +0,0 @@
|
||||
"""The knowledge vault (architecture L10; Development Plan M5.1).
|
||||
|
||||
A folder of Markdown notes on the agent host, in the plain format SilverBullet
|
||||
reads (frontmatter, ``[[links]]``, ``#tags``), so the operator browses it there.
|
||||
|
||||
- **Only configured folders exist** for the agent, each read-write or
|
||||
read-only; every folder has one writer. Paths are checked in code before
|
||||
anything is touched: relative, inside an allowed folder (symlinks
|
||||
included), Markdown, and never the editor's own files. Per-agent mounts
|
||||
enforce the same at the OS level once the agent runs in a container (M6.1).
|
||||
- **Writes are atomic** (write a temporary file beside the note, sync it,
|
||||
rename it into place), so a crash, SilverBullet or anyone reading the
|
||||
folder never sees half a note, and they **record provenance** in the
|
||||
note's frontmatter.
|
||||
- **Failures are results, never raised into the loop:** a failed read or
|
||||
write never blocks work.
|
||||
- **Notes are data, never instructions** (L10, L11): the tools label what
|
||||
they return with its path.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
READ_WRITE = "rw"
|
||||
READ_ONLY = "ro"
|
||||
ACCESS = (READ_WRITE, READ_ONLY)
|
||||
# The editor's and tools' own files: hidden ones (SilverBullet's and
|
||||
# Obsidian's settings, .git, a write's temporary file) and SilverBullet's plugs.
|
||||
DEFAULT_IGNORE = (".*", "_plug")
|
||||
NOTE_SUFFIX = ".md"
|
||||
MAX_NOTE_CHARS = 20_000 # what one read returns; the rest is said to be cut
|
||||
MAX_LISTED = 200
|
||||
MAX_FOUND = 20
|
||||
LINES_PER_NOTE = 3
|
||||
|
||||
|
||||
class VaultError(Exception):
|
||||
"""What a vault operation couldn't do, in words for the model."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Found:
|
||||
path: str
|
||||
lines: tuple[str, ...] # the matching lines, first few
|
||||
hits: int
|
||||
|
||||
|
||||
class Vault:
|
||||
"""``folders``: vault-relative folder -> "rw" or "ro". Nothing outside
|
||||
them is visible."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
folders: Mapping[str, str],
|
||||
*,
|
||||
ignore: Sequence[str] = DEFAULT_IGNORE,
|
||||
agent: str = "nisse-dev",
|
||||
clock: Any = None,
|
||||
) -> None:
|
||||
self.root = Path(root)
|
||||
self._folders = {_clean(f): a for f, a in folders.items()}
|
||||
self._ignore = tuple(ignore)
|
||||
self._agent = agent
|
||||
self._clock = clock or (lambda: datetime.now(UTC))
|
||||
for folder, access in self._folders.items():
|
||||
if access == READ_WRITE:
|
||||
(self.root / folder).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def folders(self) -> dict[str, str]:
|
||||
return dict(self._folders)
|
||||
|
||||
# -- paths -----------------------------------------------------------------
|
||||
|
||||
def _folder_of(self, path: PurePosixPath) -> str | None:
|
||||
for folder in self._folders:
|
||||
if path == PurePosixPath(folder) or PurePosixPath(folder) in path.parents:
|
||||
return folder
|
||||
return None
|
||||
|
||||
def _hidden(self, path: PurePosixPath) -> bool:
|
||||
return any(
|
||||
fnmatch.fnmatchcase(part, pattern)
|
||||
for part in path.parts
|
||||
for pattern in self._ignore
|
||||
)
|
||||
|
||||
def _resolve(self, path: str, *, note: bool) -> tuple[PurePosixPath, Path, str]:
|
||||
"""The checked path, where it is on disk, and its folder's access."""
|
||||
rel = _relative(path)
|
||||
folder = self._folder_of(rel)
|
||||
if folder is None:
|
||||
raise VaultError(
|
||||
f"{path}: not in a folder you can see; they are "
|
||||
+ ", ".join(sorted(self._folders))
|
||||
)
|
||||
if self._hidden(rel):
|
||||
raise VaultError(f"{path}: the editor's own files aren't notes")
|
||||
if note and rel.suffix != NOTE_SUFFIX:
|
||||
raise VaultError(f"{path}: notes are Markdown files ending in .md")
|
||||
real = (self.root / rel).resolve()
|
||||
top = (self.root / folder).resolve()
|
||||
if real != top and top not in real.parents:
|
||||
raise VaultError(f"{path}: leads outside its folder")
|
||||
return rel, real, self._folders[folder]
|
||||
|
||||
# -- reading ---------------------------------------------------------------
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
_, real, _ = self._resolve(path, note=True)
|
||||
try:
|
||||
text = real.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
raise VaultError(f"{path}: no such note") from None
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise VaultError(f"{path}: couldn't read it ({exc})") from None
|
||||
if len(text) > MAX_NOTE_CHARS:
|
||||
cut = len(text) - MAX_NOTE_CHARS
|
||||
text = f"{text[:MAX_NOTE_CHARS]}\n\n[{cut} more characters not shown]"
|
||||
return text
|
||||
|
||||
def notes(self, folder: str = "") -> list[str]:
|
||||
"""Notes under ``folder`` (every visible folder when empty)."""
|
||||
tops = (
|
||||
[self._resolve(folder, note=False)[0]]
|
||||
if folder.strip("/ ")
|
||||
else [PurePosixPath(f) for f in sorted(self._folders)]
|
||||
)
|
||||
return sorted(p for top in tops for p in self._notes_under(top))
|
||||
|
||||
def _notes_under(self, top: PurePosixPath) -> list[str]:
|
||||
base = (self.root / top).resolve()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
found = []
|
||||
for dirpath, dirnames, filenames in os.walk(base):
|
||||
here = PurePosixPath(Path(dirpath).relative_to(self.root.resolve()))
|
||||
dirnames[:] = sorted(d for d in dirnames if not self._hidden(here / d))
|
||||
for name in sorted(filenames):
|
||||
rel = here / name
|
||||
if rel.suffix != NOTE_SUFFIX or self._hidden(rel):
|
||||
continue
|
||||
real = (self.root / rel).resolve()
|
||||
if real == base or base in real.parents: # no links out
|
||||
found.append(str(rel))
|
||||
return found
|
||||
|
||||
def search(self, text: str) -> list[Found]:
|
||||
"""Notes whose path or text contains ``text``, ignoring case; most
|
||||
matches first."""
|
||||
needle = text.strip().lower()
|
||||
if not needle:
|
||||
raise VaultError("search for some text")
|
||||
found = []
|
||||
for path in self.notes():
|
||||
try:
|
||||
_, real, _ = self._resolve(path, note=True)
|
||||
body = real.read_text(encoding="utf-8")
|
||||
except (VaultError, OSError, UnicodeDecodeError):
|
||||
continue # a failed read never blocks the rest
|
||||
lines = [ln.strip() for ln in body.splitlines() if needle in ln.lower()]
|
||||
hits = len(lines) + (needle in path.lower())
|
||||
if hits:
|
||||
found.append(Found(path, tuple(lines[:LINES_PER_NOTE]), hits))
|
||||
found.sort(key=lambda f: (-f.hits, f.path))
|
||||
return found[:MAX_FOUND]
|
||||
|
||||
# -- writing ---------------------------------------------------------------
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
"""Remove a note in a read-write folder (one writer: its own)."""
|
||||
_, real, access = self._resolve(path, note=True)
|
||||
if access != READ_WRITE:
|
||||
raise VaultError(f"{path}: that folder is read-only for you")
|
||||
try:
|
||||
real.unlink()
|
||||
except FileNotFoundError:
|
||||
raise VaultError(f"{path}: no such note") from None
|
||||
except OSError as exc:
|
||||
raise VaultError(f"{path}: couldn't remove it ({exc})") from None
|
||||
|
||||
def write(self, path: str, text: str, *, provenance: Mapping[str, str]) -> None:
|
||||
"""Write a note in a read-write folder, atomically, with where it
|
||||
came from merged into its frontmatter; the rest of its frontmatter
|
||||
is kept."""
|
||||
_, real, access = self._resolve(path, note=True)
|
||||
if access != READ_WRITE:
|
||||
raise VaultError(f"{path}: that folder is read-only for you")
|
||||
front, body = split_frontmatter(text)
|
||||
front["provenance"] = {
|
||||
"by": self._agent,
|
||||
**dict(provenance),
|
||||
"written": self._clock().isoformat(timespec="seconds"),
|
||||
}
|
||||
content = (
|
||||
"---\n"
|
||||
+ yaml.safe_dump(front, sort_keys=False, allow_unicode=True)
|
||||
+ "---\n"
|
||||
+ body
|
||||
)
|
||||
try:
|
||||
real.parent.mkdir(parents=True, exist_ok=True)
|
||||
_replace(real, content)
|
||||
except OSError as exc:
|
||||
raise VaultError(f"{path}: couldn't write it ({exc})") from None
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""A note's frontmatter (a YAML mapping between --- lines at the top)
|
||||
and the rest. Raises VaultError for frontmatter that isn't a mapping."""
|
||||
if not text.startswith("---\n"):
|
||||
return {}, text
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return {}, text
|
||||
try:
|
||||
front = yaml.safe_load(text[4:end]) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise VaultError(f"its frontmatter isn't valid YAML ({exc})") from None
|
||||
if not isinstance(front, dict):
|
||||
raise VaultError("its frontmatter must be a mapping")
|
||||
return front, text[end + 5 :]
|
||||
|
||||
|
||||
def _relative(path: str) -> PurePosixPath:
|
||||
rel = PurePosixPath(path.strip())
|
||||
if not path.strip() or rel.is_absolute() or ".." in rel.parts or "\\" in path:
|
||||
raise VaultError(f"{path!r}: give a path inside the vault, like notes/x.md")
|
||||
return rel
|
||||
|
||||
|
||||
def _clean(folder: str) -> str:
|
||||
return str(_relative(folder.strip("/")))
|
||||
|
||||
|
||||
def _replace(target: Path, content: str) -> None:
|
||||
"""Write-then-rename in the note's own directory (L10): readers see the
|
||||
old note or the new one, never part of either. The temporary file is
|
||||
hidden, so it is never listed, and removed if anything fails."""
|
||||
fd, temp = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp, target)
|
||||
except BaseException:
|
||||
Path(temp).unlink(missing_ok=True)
|
||||
raise
|
||||
directory = os.open(target.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory) # the rename itself survives a crash
|
||||
finally:
|
||||
os.close(directory)
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Per-role views of a work item's transcript (architecture L6; Development
|
||||
Plan M1.2).
|
||||
|
||||
The log records every role's events in one work item. A model must not read
|
||||
another role's turns as its own: in the second live run the planner, shown
|
||||
the executor's tool calls as assistant turns, took that work for its own
|
||||
(#78). So what a role is shown is projected here, before rendering:
|
||||
|
||||
- its own events, unchanged;
|
||||
- what the operator said, as a user message labelled with where it came from;
|
||||
- the orchestrator's facts and gates, as labelled statements;
|
||||
- another model role's work only where it needs it, and only as a labelled
|
||||
report or narration, never as a turn.
|
||||
|
||||
The projection keeps each event's id, so cursors (``after``) and compaction
|
||||
ranges still line up with the log. Everything not the viewer's own becomes a
|
||||
``UserMessage``: from where the viewer sits, it is all input.
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
ActionVerdict,
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
# How much of another role's tool result a narration line keeps.
|
||||
NARRATION_RESULT_CHARS = 300
|
||||
|
||||
# The roles whose work the viewer is shown, and how: "report" keeps only the
|
||||
# handback, "narrate" keeps a line per step. Anything not listed is not shown.
|
||||
_SHOWN: dict[str, dict[str, str]] = {
|
||||
"driver": {"helper": "report"},
|
||||
"helper": {"driver": "narrate"},
|
||||
# The toolchain step works in its own transcript: the driver sees only
|
||||
# its report (a tool result, or facts), the step the driver's work.
|
||||
"toolchain": {"driver": "narrate"},
|
||||
# The memory pass at an item's close (L6.4): the item as it went.
|
||||
"memory": {"driver": "narrate"},
|
||||
}
|
||||
|
||||
|
||||
def operator_label(source: str) -> str:
|
||||
"""Where an operator message came from, for its label."""
|
||||
origin, _, idle = source.partition(" (sent while idle")
|
||||
if origin.startswith("transport:"):
|
||||
channel = "Matrix"
|
||||
elif origin.startswith("gitea:pr:"):
|
||||
number = origin.split(":")[2]
|
||||
channel = f"comment on PR #{number}"
|
||||
elif origin.startswith("gitea:issue-body:"):
|
||||
channel = f"issue #{origin.split(':')[2]}"
|
||||
elif origin.startswith("gitea:issue:"):
|
||||
number = origin.split(":")[2]
|
||||
channel = f"comment on plan issue #{number}"
|
||||
elif origin.startswith("gitea:"):
|
||||
channel = "Gitea comment"
|
||||
else:
|
||||
channel = origin or "unknown"
|
||||
if idle:
|
||||
channel += ", sent while idle"
|
||||
return f"operator, {channel}"
|
||||
|
||||
|
||||
def _said(stored: StoredEvent, label: str, text: str) -> StoredEvent:
|
||||
return replace(
|
||||
stored,
|
||||
event=UserMessage(text=f"[{label}] {text}", source=f"view:{stored.actor}"),
|
||||
)
|
||||
|
||||
|
||||
def _narration(call: ToolCall, result: ToolResult | None) -> str:
|
||||
target = call.arguments.get("path") or call.arguments.get("command")
|
||||
step = f"{call.tool}({str(target)[:80]})" if target else call.tool
|
||||
if result is None:
|
||||
return f"{step} → no result recorded"
|
||||
outcome = "ok" if result.success else "failed"
|
||||
text = result.result.strip()
|
||||
if len(text) > NARRATION_RESULT_CHARS:
|
||||
text = text[:NARRATION_RESULT_CHARS] + " …"
|
||||
return f"{step} → {outcome}: {text}" if text else f"{step} → {outcome}"
|
||||
|
||||
|
||||
def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
||||
"""The events as ``viewer`` should be shown them, in log order."""
|
||||
# Verdicts are records about calls, never part of any role's context.
|
||||
events = [e for e in events if not isinstance(e.event, ActionVerdict)]
|
||||
shown = _SHOWN.get(viewer)
|
||||
if shown is None:
|
||||
# A role with no view defined (the expert is stateless, the
|
||||
# classifier sees only an action) reads the log as it is, with
|
||||
# results kept after their calls.
|
||||
return _results_follow_calls(list(events))
|
||||
results = {
|
||||
e.event.call_id: e.event for e in events if isinstance(e.event, ToolResult)
|
||||
}
|
||||
out: list[StoredEvent] = []
|
||||
for stored in events:
|
||||
event, actor = stored.event, stored.actor
|
||||
if isinstance(event, PlanSubmitted):
|
||||
# A record for a helper's scope and the dump, not a turn: the
|
||||
# driver's own call already carries what was agreed.
|
||||
if viewer == "helper":
|
||||
out.append(_said(stored, "approved plan", event.plan))
|
||||
continue
|
||||
if isinstance(event, DelegationStarted):
|
||||
# The harness handing a task over: to the helper given it, a
|
||||
# message from the harness, never its own turn (a request must
|
||||
# end on the user's side; models refuse assistant prefill).
|
||||
if actor == viewer:
|
||||
out.append(_said(stored, "orchestrator", f"your task: {event.brief}"))
|
||||
continue
|
||||
if actor == viewer:
|
||||
out.append(stored)
|
||||
elif actor == "operator" and isinstance(event, UserMessage):
|
||||
out.append(_said(stored, operator_label(event.source), event.text))
|
||||
elif isinstance(event, Fact):
|
||||
out.append(_said(stored, "orchestrator", event.text))
|
||||
elif isinstance(event, GateOpened):
|
||||
out.append(
|
||||
_said(stored, "orchestrator", f"gate {event.gate}: {event.reason}")
|
||||
)
|
||||
elif shown.get(actor) == "report" and isinstance(event, DelegationCompleted):
|
||||
out.append(
|
||||
_said(
|
||||
stored,
|
||||
f"{actor} report, {event.exit_status}",
|
||||
event.handback,
|
||||
)
|
||||
)
|
||||
elif shown.get(actor) == "narrate":
|
||||
if isinstance(event, ToolCall):
|
||||
line = _narration(event, results.get(event.call_id))
|
||||
out.append(_said(stored, actor, line))
|
||||
elif isinstance(event, AssistantText) and event.text.strip():
|
||||
out.append(_said(stored, actor, event.text.strip()))
|
||||
return _results_follow_calls(out)
|
||||
|
||||
|
||||
def _results_follow_calls(events: list[StoredEvent]) -> list[StoredEvent]:
|
||||
"""Keep each tool result right after its call, as both API formats
|
||||
require: anything recorded while a call was in flight (a fact the
|
||||
harness wrote while handling it, say) moves to just after the results.
|
||||
A call with no result yet keeps its place; the renderer answers it."""
|
||||
out: list[StoredEvent] = []
|
||||
pending: set[str] = set()
|
||||
held: list[StoredEvent] = []
|
||||
for stored in events:
|
||||
event = stored.event
|
||||
if isinstance(event, ToolCall):
|
||||
pending.add(event.call_id)
|
||||
out.append(stored)
|
||||
elif isinstance(event, ToolResult) and event.call_id in pending:
|
||||
pending.discard(event.call_id)
|
||||
out.append(stored)
|
||||
if not pending:
|
||||
out.extend(held)
|
||||
held = []
|
||||
elif pending:
|
||||
held.append(stored)
|
||||
else:
|
||||
out.append(stored)
|
||||
return out + held
|
||||
@@ -1,138 +0,0 @@
|
||||
"""The one thread that runs orchestrator work and owns the state store.
|
||||
|
||||
Planning turns, milestones and review responses can each run for minutes.
|
||||
Running them on the thread that delivered the message blocked that thread for
|
||||
as long: the Matrix sync loop read nothing new until the work finished, so an
|
||||
operator's `stop` (L2) would only have arrived after the work it was meant to
|
||||
stop. Every handler now runs here instead, and transports only hand work over.
|
||||
|
||||
Owning the store is the other half. SQLite connections are bound to the thread
|
||||
that opened them, and the store keeps that check on: it is opened on this
|
||||
thread, used only from it, and any future code that touches it from elsewhere
|
||||
fails loudly instead of racing.
|
||||
|
||||
Jobs run one at a time, in order, so state changes never interleave — the
|
||||
guarantee the orchestrator's lock used to give.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from queue import Queue
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Worker(Protocol):
|
||||
@property
|
||||
def busy(self) -> bool: ...
|
||||
|
||||
def submit(self, job: Callable[[], object]) -> None: ...
|
||||
|
||||
def call(self, job: Callable[[], T], timeout: float | None = None) -> T: ...
|
||||
|
||||
|
||||
class InlineWorker:
|
||||
"""Runs jobs on the caller's thread, for tests and single-threaded use.
|
||||
|
||||
A job submitted from inside a running job waits until that one finishes,
|
||||
exactly as it would queue behind it on the real worker. Without that, a
|
||||
test that says `stop` from inside a fake milestone would run the stop
|
||||
handling in the middle of the milestone, which production never does.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: deque[Callable[[], object]] = deque()
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def submit(self, job: Callable[[], object]) -> None:
|
||||
self._pending.append(job)
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
try:
|
||||
while self._pending:
|
||||
self._pending.popleft()()
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def call(self, job: Callable[[], T], timeout: float | None = None) -> T:
|
||||
if self._running:
|
||||
return job() # already on the worker, as a nested call would be
|
||||
box: list[T] = []
|
||||
self.submit(lambda: box.append(job()))
|
||||
return box[0]
|
||||
|
||||
|
||||
_STOP = object()
|
||||
|
||||
|
||||
def _report(exc: BaseException) -> None:
|
||||
traceback.print_exception(exc, file=sys.stderr)
|
||||
|
||||
|
||||
class SerialWorker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_error: Callable[[BaseException], None] = _report,
|
||||
name: str = "nisse-worker",
|
||||
) -> None:
|
||||
self._queue: Queue[object] = Queue()
|
||||
self._on_error = on_error
|
||||
self._busy = False
|
||||
self._thread = threading.Thread(target=self._run, name=name, daemon=True)
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
return self._busy
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def submit(self, job: Callable[[], object]) -> None:
|
||||
self._queue.put((job, None))
|
||||
|
||||
def call(self, job: Callable[[], T], timeout: float | None = None) -> T:
|
||||
"""Run a job here and wait for its result. Raises TimeoutError if it
|
||||
has not finished in time; the job itself still runs."""
|
||||
if threading.current_thread() is self._thread:
|
||||
return job()
|
||||
future: Future[T] = Future()
|
||||
self._queue.put((job, future))
|
||||
return future.result(timeout)
|
||||
|
||||
def stop(self, timeout: float | None = None) -> None:
|
||||
self._queue.put(_STOP)
|
||||
self._thread.join(timeout)
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
item = self._queue.get()
|
||||
if item is _STOP:
|
||||
return
|
||||
job, future = item # type: ignore[misc]
|
||||
self._busy = True
|
||||
try:
|
||||
result = job()
|
||||
except Exception as exc: # noqa: BLE001 -- see below
|
||||
# One failed job must not take the agent's only worker with
|
||||
# it. A caller that waits gets the exception; otherwise it is
|
||||
# reported and the next job runs.
|
||||
if future is not None:
|
||||
future.set_exception(exc)
|
||||
else:
|
||||
self._on_error(exc)
|
||||
else:
|
||||
if future is not None:
|
||||
future.set_result(result)
|
||||
finally:
|
||||
self._busy = False
|
||||
@@ -10,8 +10,8 @@ unit tests and be rejected live; this makes it fail here.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.render import OpenAIChatRenderer
|
||||
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||
from nisse_core.render import OpenAIChatRenderer
|
||||
from nisse_core.render_anthropic import AnthropicMessagesRenderer
|
||||
|
||||
|
||||
def assert_renderable(events: list[Any]) -> None:
|
||||
|
||||
@@ -11,13 +11,13 @@ import httpx
|
||||
import pytest
|
||||
from test_config import SECRETS, P, tree
|
||||
|
||||
from nisse_dev.catalog import ModelCatalog, UnknownModel
|
||||
from nisse_core.catalog import ModelCatalog, UnknownModel
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_dev.config import AgentConfig
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_dev.loop.build import RunningLoop, build_loop_agent, discover_models
|
||||
from nisse_dev.loop.build import prepare_workspace as prepare
|
||||
from nisse_dev.oplog import OpLog
|
||||
|
||||
# What the test roles resolve to, as LiteLLM would report them.
|
||||
TEST_CATALOG = ModelCatalog.from_payload(
|
||||
@@ -143,7 +143,7 @@ def offline(monkeypatch: Any, origins: dict[str, str] | str, catalog=None) -> No
|
||||
"""Only the network boundaries faked: Gitea, LiteLLM's model list, Matrix."""
|
||||
# The proxy here doesn't exist: a call to it fails at once rather than
|
||||
# waiting out an outage's backoff (L5).
|
||||
monkeypatch.setattr("nisse_dev.llm.OUTAGE_BACKOFF_S", ())
|
||||
monkeypatch.setattr("nisse_core.llm.OUTAGE_BACKOFF_S", ())
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.loop.build.GiteaClient.whoami", lambda self: "nisse-dev-testing"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.canonical import AssistantText, ToolCall
|
||||
from nisse_core.catalog import ModelFacts
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_dev.bench.results import (
|
||||
append,
|
||||
load,
|
||||
@@ -17,10 +20,7 @@ from nisse_dev.bench.results import (
|
||||
)
|
||||
from nisse_dev.bench.runner import MeteringClient, run_task
|
||||
from nisse_dev.bench.tasks import CHECK_DIR, load_tasks
|
||||
from nisse_dev.canonical import AssistantText, ToolCall
|
||||
from nisse_dev.catalog import ModelFacts
|
||||
from nisse_dev.config import DriverTuning, Tuning
|
||||
from nisse_dev.execution import SSHHost
|
||||
|
||||
# The build VM for every test here: the session's throwaway sshd.
|
||||
HOST: SSHHost
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
from_payload,
|
||||
mint_call_id,
|
||||
)
|
||||
from nisse_dev.state import StateSchemaError, StateStore
|
||||
|
||||
ALL_EVENTS = [
|
||||
UserMessage(text="use demo", source="matrix:$abc"),
|
||||
AssistantText(text="starting", model="local/qwen"),
|
||||
ToolCall(call_id="call-1", tool="run_tests", arguments={"path": "tests/"}),
|
||||
ToolResult(call_id="call-1", result="3 passed", success=True),
|
||||
PlanSubmitted(plan="the plan", model="claude-opus-5"),
|
||||
GateOpened(gate="arch", reason="plan submitted (rev-1)"),
|
||||
DelegationStarted(
|
||||
brief="fix the flaky test",
|
||||
role="helper",
|
||||
model="claude-opus-5",
|
||||
caps={"turns": 20, "tokens": 100000},
|
||||
),
|
||||
DelegationCompleted(
|
||||
handback="test fixed; root cause was a shared tmpdir",
|
||||
artifacts=["nisse-dev/tests/test_x.py"],
|
||||
tokens=51234,
|
||||
duration_s=61.5,
|
||||
exit_status="success",
|
||||
),
|
||||
Compaction(summary="earlier work summarized", model="local/qwen", replaces=[1, 4]),
|
||||
]
|
||||
|
||||
|
||||
def test_all_event_types_round_trip(tmp_path: Path) -> None:
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
for event in ALL_EVENTS:
|
||||
store.transcript("t", "executor").append(event)
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
stored = store.transcript("t", "executor").read()
|
||||
assert [s.event for s in stored] == ALL_EVENTS
|
||||
assert [s.id for s in stored] == sorted(s.id for s in stored)
|
||||
assert {(s.work_item, s.actor) for s in stored} == {("t", "executor")}
|
||||
|
||||
|
||||
def test_read_events_after_id(tmp_path: Path) -> None:
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
log = store.transcript("t", "operator")
|
||||
first = log.append(UserMessage(text="one", source="m:1"))
|
||||
log.append(UserMessage(text="two", source="m:2"))
|
||||
tail = log.read(after=first)
|
||||
assert [s.event.text for s in tail] == ["two"]
|
||||
|
||||
|
||||
def test_mint_call_id_is_unique_and_prefixed() -> None:
|
||||
ids = {mint_call_id() for _ in range(100)}
|
||||
assert len(ids) == 100
|
||||
assert all(i.startswith("call-") for i in ids)
|
||||
|
||||
|
||||
def test_unknown_event_type_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unknown canonical event type"):
|
||||
from_payload("mystery", {})
|
||||
|
||||
|
||||
def test_unknown_fields_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unknown fields"):
|
||||
from_payload("user_message", {"text": "hi", "source": "m", "extra": 1})
|
||||
|
||||
|
||||
# The v1 schema as it shipped (PR #2), frozen here to prove a newer image
|
||||
# opens and upgrades a database written by the older one (L8).
|
||||
V1_SCHEMA = [
|
||||
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
"""
|
||||
CREATE TABLE agent_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state TEXT NOT NULL,
|
||||
project TEXT,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE intent_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
def test_a_store_from_before_work_items_is_refused_clearly(tmp_path: Path) -> None:
|
||||
# No production data predates work items, so there is no migration from
|
||||
# these versions (Development Plan M1.1): the store must say so rather
|
||||
# than open half-understood.
|
||||
db = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(db)
|
||||
for statement in V1_SCHEMA:
|
||||
conn.execute(statement)
|
||||
conn.execute("INSERT INTO meta (key, value) VALUES ('schema_version', '3')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with pytest.raises(StateSchemaError, match="predates work items"):
|
||||
StateStore(db)
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Runtime model discovery (architecture L9).
|
||||
|
||||
The point of these tests is that nothing here reads the model's *name*. A
|
||||
semantic alias like "remote-architecture" is free to point anywhere, so the
|
||||
route has to come from what LiteLLM says the model is.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.catalog import (
|
||||
CatalogUnavailable,
|
||||
ModelCatalog,
|
||||
UnknownModel,
|
||||
select_client,
|
||||
)
|
||||
|
||||
|
||||
def entry(alias: str, backing: str, provider: str | None, tools: bool | None = None):
|
||||
"""One /model/info row, shaped as the live proxy returns it."""
|
||||
info: dict = {"supports_function_calling": tools}
|
||||
if provider is not None:
|
||||
info["litellm_provider"] = provider
|
||||
return {
|
||||
"model_name": alias,
|
||||
"litellm_params": {"model": backing, "api_base": "http://proxy.test/v1"},
|
||||
"model_info": info,
|
||||
}
|
||||
|
||||
|
||||
def catalog(*entries) -> ModelCatalog:
|
||||
return ModelCatalog.from_payload({"data": list(entries)})
|
||||
|
||||
|
||||
# --- parsing -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_facts_are_read_from_the_payload() -> None:
|
||||
cat = catalog(
|
||||
entry("claude-haiku-4-5", "anthropic/claude-haiku-4-5", "anthropic", True)
|
||||
)
|
||||
facts = cat.facts("claude-haiku-4-5")
|
||||
assert facts.backing == "anthropic/claude-haiku-4-5"
|
||||
assert facts.provider == "anthropic"
|
||||
assert facts.supports_tools is True
|
||||
assert facts.native_anthropic
|
||||
|
||||
|
||||
def test_provider_falls_back_to_the_backing_prefix() -> None:
|
||||
# litellm_provider is absent on some deployments; the backing model's
|
||||
# own "vendor/model" prefix says the same thing.
|
||||
cat = catalog(entry("mystery", "anthropic/claude-opus-5", None))
|
||||
assert cat.facts("mystery").provider == "anthropic"
|
||||
|
||||
|
||||
def test_unnamed_entries_are_skipped() -> None:
|
||||
cat = ModelCatalog.from_payload({"data": [{"litellm_params": {"model": "x"}}]})
|
||||
assert cat.aliases() == []
|
||||
|
||||
|
||||
def test_missing_tool_support_is_unknown_not_false() -> None:
|
||||
# LiteLLM reports null for models it has no capability data on. That is
|
||||
# not the same as declaring the model tool-less, and the startup warning
|
||||
# depends on the difference.
|
||||
cat = catalog(entry("local-fast", "openai/qwen3-8b-awq", "openai", None))
|
||||
assert cat.facts("local-fast").supports_tools is None
|
||||
|
||||
|
||||
# --- route selection by provider, never by name --------------------------
|
||||
|
||||
|
||||
def test_a_semantic_alias_routes_by_its_provider() -> None:
|
||||
# The whole feature: nothing in "remote-architecture" says Anthropic.
|
||||
cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic"))
|
||||
normalized, passthrough = object(), object()
|
||||
chosen = select_client("remote-architecture", normalized, passthrough, cat)
|
||||
assert chosen is passthrough
|
||||
|
||||
|
||||
def test_the_same_alias_reroutes_when_it_is_repointed() -> None:
|
||||
# L9: provisionally accept LiteLLM's normalization for a vendor with no
|
||||
# native adapter, rather than sending it Anthropic-shaped bodies.
|
||||
cat = catalog(entry("remote-architecture", "openai/gpt-6-astra", "openai"))
|
||||
normalized, passthrough = object(), object()
|
||||
assert (
|
||||
select_client("remote-architecture", normalized, passthrough, cat) is normalized
|
||||
)
|
||||
|
||||
|
||||
def test_a_name_that_merely_mentions_a_vendor_is_not_that_vendor() -> None:
|
||||
cat = catalog(entry("claude-clone-9", "openai/some-clone", "openai"))
|
||||
normalized, passthrough = object(), object()
|
||||
assert select_client("claude-clone-9", normalized, passthrough, cat) is normalized
|
||||
|
||||
|
||||
def test_local_models_take_the_normalized_route() -> None:
|
||||
cat = catalog(
|
||||
entry("local-coder", "openai/qwen3-14b", "openai"),
|
||||
entry("local-fast", "openai/qwen3-8b-awq", "openai"),
|
||||
)
|
||||
normalized, passthrough = object(), object()
|
||||
for alias in ("local-coder", "local-fast"):
|
||||
assert select_client(alias, normalized, passthrough, cat) is normalized
|
||||
|
||||
|
||||
# --- misconfiguration is loud --------------------------------------------
|
||||
|
||||
|
||||
def test_an_unknown_model_raises_rather_than_guessing_a_route() -> None:
|
||||
cat = catalog(entry("local-fast", "openai/qwen3-8b-awq", "openai"))
|
||||
with pytest.raises(UnknownModel, match="local-fsat"):
|
||||
select_client("local-fsat", object(), object(), cat)
|
||||
|
||||
|
||||
def test_the_error_lists_what_is_available() -> None:
|
||||
cat = catalog(
|
||||
entry("local-fast", "openai/qwen3-8b-awq", "openai"),
|
||||
entry("claude-opus-5", "anthropic/claude-opus-5", "anthropic"),
|
||||
)
|
||||
with pytest.raises(UnknownModel, match="claude-opus-5, local-fast"):
|
||||
cat.facts("typo")
|
||||
|
||||
|
||||
def test_validate_names_every_bad_role_at_once() -> None:
|
||||
# One boot should surface the whole list, not make the operator
|
||||
# rediscover the next typo on the next start.
|
||||
cat = catalog(entry("local-fast", "openai/qwen3-8b-awq", "openai"))
|
||||
with pytest.raises(UnknownModel) as caught:
|
||||
cat.validate(
|
||||
{
|
||||
"executor": "local-fast",
|
||||
"planner": "clause-opus-5",
|
||||
"helper": "remote-architecture",
|
||||
"expert": "local-fast",
|
||||
}
|
||||
)
|
||||
message = str(caught.value)
|
||||
assert "planner='clause-opus-5'" in message
|
||||
assert "helper='remote-architecture'" in message
|
||||
assert "executor" not in message # the good ones are not noise
|
||||
|
||||
|
||||
def test_validate_passes_a_whole_good_mapping() -> None:
|
||||
cat = catalog(
|
||||
entry("local-fast", "openai/qwen3-8b-awq", "openai"),
|
||||
entry("claude-opus-5", "anthropic/claude-opus-5", "anthropic"),
|
||||
)
|
||||
cat.validate({"executor": "local-fast", "planner": "claude-opus-5"})
|
||||
|
||||
|
||||
# --- an outage is not a misconfiguration ---------------------------------
|
||||
|
||||
|
||||
def test_an_unreachable_proxy_is_reported_as_an_outage() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("no route to host")
|
||||
|
||||
with pytest.raises(CatalogUnavailable, match="could not reach"):
|
||||
ModelCatalog.discover(
|
||||
"http://litellm.test", "sk-test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def test_a_refused_listing_is_reported_as_an_outage() -> None:
|
||||
cat = httpx.MockTransport(lambda request: httpx.Response(401, text="bad key"))
|
||||
with pytest.raises(CatalogUnavailable, match="401"):
|
||||
ModelCatalog.discover("http://litellm.test", "sk-test", transport=cat)
|
||||
|
||||
|
||||
def test_discovery_asks_the_proxy_with_the_token() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("Authorization")
|
||||
return httpx.Response(
|
||||
200, json={"data": [entry("local-fast", "openai/qwen3-8b-awq", "openai")]}
|
||||
)
|
||||
|
||||
cat = ModelCatalog.discover(
|
||||
"http://litellm.test/", "sk-test", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
assert seen["url"] == "http://litellm.test/model/info"
|
||||
assert seen["auth"] == "Bearer sk-test"
|
||||
assert cat.aliases() == ["local-fast"]
|
||||
|
||||
|
||||
# --- what gets pinned ----------------------------------------------------
|
||||
|
||||
|
||||
def test_pinning_records_what_the_alias_resolved_to() -> None:
|
||||
# An alias may point somewhere else tomorrow; eval history (L9) keyed on
|
||||
# the alias alone would average across whatever it pointed at.
|
||||
cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic"))
|
||||
pinned = cat.pinned({"planner": "remote-architecture"})
|
||||
assert pinned == {
|
||||
"planner": {
|
||||
"alias": "remote-architecture",
|
||||
"backing": "anthropic/claude-opus-5",
|
||||
"provider": "anthropic",
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.llm import DEFAULT_REQUEST_TIMEOUT_S
|
||||
from nisse_dev.config import AgentConfig, ConfigError, Project
|
||||
from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S
|
||||
|
||||
# A complete config as the YAML file would give it, with secrets by reference.
|
||||
COMPLETE: dict[str, Any] = {
|
||||
@@ -142,7 +142,7 @@ def test_request_timeout_is_configurable_and_checked() -> None:
|
||||
|
||||
|
||||
def test_the_request_timeout_reaches_both_clients() -> None:
|
||||
from nisse_dev.catalog import ModelCatalog
|
||||
from nisse_core.catalog import ModelCatalog
|
||||
from nisse_dev.loop.build import make_llm_clients
|
||||
|
||||
normalized, passthrough = make_llm_clients(
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"""The context budget (architecture L6)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.catalog import ModelCatalog, UnknownLimits
|
||||
from nisse_dev.context import (
|
||||
Budget,
|
||||
BudgetedClient,
|
||||
ContextOverflow,
|
||||
estimate_tokens,
|
||||
fit,
|
||||
head_and_tail,
|
||||
)
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
|
||||
def stored(events: list[CanonicalEvent]) -> list[StoredEvent]:
|
||||
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
||||
|
||||
|
||||
def turn(n: int, output: str) -> list[CanonicalEvent]:
|
||||
"""One executor turn: a command and its (possibly long) output."""
|
||||
return [
|
||||
ToolCall(call_id=f"c{n}", tool="run_command", arguments={"command": "make"}),
|
||||
ToolResult(call_id=f"c{n}", result=output, success=True),
|
||||
]
|
||||
|
||||
|
||||
def transcript(turns: int, output_chars: int) -> list[StoredEvent]:
|
||||
events: list[CanonicalEvent] = [UserMessage(text="start milestone 1", source="t")]
|
||||
for n in range(turns):
|
||||
events += turn(n, f"output {n} " + "x" * output_chars)
|
||||
return stored(events)
|
||||
|
||||
|
||||
def budget(window: int, keep: int = 2) -> Budget:
|
||||
return Budget(
|
||||
window=window, max_output=500, keep_recent_turns=keep, safety_margin=0.1
|
||||
)
|
||||
|
||||
|
||||
def results(events: list[StoredEvent]) -> dict[str, str]:
|
||||
return {
|
||||
e.event.call_id: e.event.result
|
||||
for e in events
|
||||
if isinstance(e.event, ToolResult)
|
||||
}
|
||||
|
||||
|
||||
# --- keeping the end of long output ---------------------------------------
|
||||
|
||||
|
||||
def test_head_and_tail_keeps_the_failure_summary_at_the_end() -> None:
|
||||
output = (
|
||||
"exit code: 1\nstdout:\n"
|
||||
+ "collected 400 items\n" * 500
|
||||
+ "FAILED tests/test_app.py::test_greet - AssertionError\n"
|
||||
+ "==== 1 failed, 399 passed ====\n"
|
||||
)
|
||||
kept = head_and_tail(output, 1000)
|
||||
assert len(kept) < 1100
|
||||
assert kept.startswith("exit code: 1") # the head keeps the exit code
|
||||
assert "FAILED tests/test_app.py::test_greet" in kept
|
||||
assert "1 failed, 399 passed" in kept
|
||||
assert "characters omitted" in kept
|
||||
|
||||
|
||||
def test_short_text_is_untouched() -> None:
|
||||
assert head_and_tail("fine", 1000) == "fine"
|
||||
|
||||
|
||||
# --- fitting, step by step -------------------------------------------------
|
||||
|
||||
|
||||
def test_a_transcript_that_fits_is_sent_unchanged() -> None:
|
||||
events = transcript(turns=3, output_chars=100)
|
||||
assert fit(events, "system", None, budget(100_000)) == events
|
||||
|
||||
|
||||
def test_step_one_cuts_older_tool_results_and_keeps_recent_ones_whole() -> None:
|
||||
events = transcript(turns=6, output_chars=3000)
|
||||
fitted = fit(events, "system", None, budget(4000, keep=2))
|
||||
|
||||
kept = results(fitted)
|
||||
assert len(kept) == 6 # nothing dropped yet
|
||||
for older in ("c0", "c1", "c2", "c3"):
|
||||
assert "characters omitted" in kept[older]
|
||||
for recent in ("c4", "c5"):
|
||||
assert "characters omitted" not in kept[recent]
|
||||
|
||||
|
||||
def test_step_two_drops_the_oldest_turns_and_says_so() -> None:
|
||||
events = transcript(turns=20, output_chars=3000)
|
||||
fitted = fit(events, "system", None, budget(4000, keep=2))
|
||||
|
||||
first = fitted[0].event
|
||||
assert isinstance(first, UserMessage)
|
||||
assert "omitted to fit the context window" in first.text
|
||||
kept = results(fitted)
|
||||
assert "c0" not in kept # the oldest went
|
||||
assert {"c18", "c19"} <= kept.keys() # the recent turns stayed
|
||||
assert "characters omitted" not in kept["c19"]
|
||||
# Every surviving result still has its call: a provider rejects an
|
||||
# answer to a call it never saw.
|
||||
calls = {e.event.call_id for e in fitted if isinstance(e.event, ToolCall)}
|
||||
assert kept.keys() <= calls
|
||||
assert estimate_tokens(fitted, "system", None) <= budget(4000).for_prompt
|
||||
|
||||
|
||||
def test_step_three_refuses_what_cannot_fit() -> None:
|
||||
# Even the two most recent turns are bigger than the whole window.
|
||||
events = transcript(turns=3, output_chars=20_000)
|
||||
with pytest.raises(ContextOverflow, match="last 2 turns"):
|
||||
fit(events, "system", None, budget(2000, keep=2))
|
||||
|
||||
|
||||
def test_a_huge_system_prompt_alone_overflows() -> None:
|
||||
events = transcript(turns=1, output_chars=10)
|
||||
with pytest.raises(ContextOverflow):
|
||||
fit(events, "s" * 50_000, None, budget(4096))
|
||||
|
||||
|
||||
def test_the_output_ceiling_and_margin_come_out_of_the_window() -> None:
|
||||
b = Budget(window=4096, max_output=1024, keep_recent_turns=6, safety_margin=0.1)
|
||||
assert b.for_prompt == int(4096 * 0.9) - 1024
|
||||
|
||||
|
||||
# --- the client wrapper ----------------------------------------------------
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
def __init__(self, reported_input: int = 0) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.last_usage = {"input": reported_input, "output": 5}
|
||||
|
||||
def complete(self, events, tools, model, system=None, *, max_tokens=None, **kwargs):
|
||||
self.calls.append({"events": events, "max_tokens": max_tokens, **kwargs})
|
||||
return [AssistantText(text="ok", model=model)]
|
||||
|
||||
|
||||
def test_the_ceiling_is_the_smaller_of_config_and_model() -> None:
|
||||
inner = RecordingClient()
|
||||
client = BudgetedClient(inner, lambda m: (4096, 1024), max_output_tokens=16384)
|
||||
client.complete(transcript(1, 10), None, "llama3.2")
|
||||
assert inner.calls[0]["max_tokens"] == 1024
|
||||
|
||||
client = BudgetedClient(inner, lambda m: (200_000, 64_000), max_output_tokens=16384)
|
||||
client.complete(transcript(1, 10), None, "claude-haiku-4-5")
|
||||
assert inner.calls[1]["max_tokens"] == 16384
|
||||
|
||||
|
||||
def test_the_wrapper_sends_the_fitted_events() -> None:
|
||||
inner = RecordingClient()
|
||||
client = BudgetedClient(
|
||||
inner, lambda m: (4000, 500), keep_recent_turns=2, safety_margin=0.1
|
||||
)
|
||||
client.complete(transcript(20, 3000), None, "m", system="system")
|
||||
sent = inner.calls[0]["events"]
|
||||
assert "omitted to fit" in sent[0].event.text
|
||||
|
||||
|
||||
def test_reported_usage_only_ever_raises_the_estimate() -> None:
|
||||
events = transcript(1, 10)
|
||||
estimate = estimate_tokens(events, None, None)
|
||||
|
||||
under = RecordingClient(reported_input=estimate * 2) # we undercounted
|
||||
client = BudgetedClient(under, lambda m: (100_000, None))
|
||||
client.complete(events, None, "m")
|
||||
assert client._factors["m"] == pytest.approx(2.0, rel=0.05)
|
||||
|
||||
over = RecordingClient(reported_input=estimate // 2) # we overcounted
|
||||
under_then_over = BudgetedClient(over, lambda m: (100_000, None))
|
||||
under_then_over._factors["m"] = 2.0
|
||||
under_then_over.complete(events, None, "m")
|
||||
assert under_then_over._factors["m"] == 2.0 # never loosened
|
||||
|
||||
|
||||
def test_last_usage_passes_through() -> None:
|
||||
client = BudgetedClient(RecordingClient(reported_input=7), lambda m: (100, None))
|
||||
assert client.last_usage["input"] == 7
|
||||
|
||||
|
||||
# --- the limits come from LiteLLM's catalog --------------------------------
|
||||
|
||||
|
||||
CATALOG = ModelCatalog.from_payload(
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"model_name": "claude-haiku-4-5",
|
||||
"litellm_params": {"model": "anthropic/claude-haiku-4-5"},
|
||||
"model_info": {"max_input_tokens": 200000, "max_output_tokens": 64000},
|
||||
},
|
||||
{
|
||||
"model_name": "llama3.2",
|
||||
"litellm_params": {"model": "ollama/llama3.2"},
|
||||
"model_info": {"max_input_tokens": 4096, "max_output_tokens": 1024},
|
||||
},
|
||||
{
|
||||
"model_name": "undeclared",
|
||||
"litellm_params": {"model": "ollama/qwen3"},
|
||||
"model_info": {"max_input_tokens": None},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_the_catalog_reads_each_models_limits() -> None:
|
||||
facts = CATALOG.facts("llama3.2")
|
||||
assert (facts.max_input_tokens, facts.max_output_tokens) == (4096, 1024)
|
||||
assert CATALOG.facts("undeclared").max_input_tokens is None
|
||||
|
||||
|
||||
def test_a_role_on_a_model_without_a_window_is_refused() -> None:
|
||||
CATALOG.require_limits({"driver": "llama3.2", "helper": "claude-haiku-4-5"})
|
||||
with pytest.raises(UnknownLimits, match="driver='undeclared'"):
|
||||
CATALOG.require_limits({"driver": "undeclared", "helper": "llama3.2"})
|
||||
|
||||
|
||||
def test_a_request_that_cannot_fit_ends_the_run(tmp_path: Path) -> None:
|
||||
# Never sent: the driver's run fails and the item waits on the operator,
|
||||
# like any failed call, rather than a server getting a prompt it would
|
||||
# silently cut.
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
|
||||
store.transcript("t", "operator").append(UserMessage(text="go", source="t"))
|
||||
inner = RecordingClient()
|
||||
client = BudgetedClient(inner, lambda m: (4096, 1024))
|
||||
outcome = Driver(client, "m").run(
|
||||
store.transcript("t", "driver"),
|
||||
system="s" * 20_000, # a system prompt too big to fit
|
||||
tools=[],
|
||||
workspace=None,
|
||||
)
|
||||
|
||||
assert outcome.kind == "failed"
|
||||
assert "fit" in outcome.note
|
||||
assert inner.calls == []
|
||||
|
||||
|
||||
# --- tool_choice is only asked of servers that enforce it -------------------
|
||||
|
||||
FINISH = [{"type": "function", "function": {"name": "finish", "parameters": {}}}]
|
||||
|
||||
|
||||
def test_required_is_asked_only_of_models_declared_to_enforce_it() -> None:
|
||||
# Measured: qwen3-14b under "required" cannot stop and rambles to the
|
||||
# output ceiling, so a model that is not declared is never asked.
|
||||
inner = RecordingClient()
|
||||
client = BudgetedClient(
|
||||
inner, lambda m: (32768, 8192), enforces_tool_choice=lambda m: m == "local-fast"
|
||||
)
|
||||
client.complete(transcript(1, 10), FINISH, "local-fast")
|
||||
client.complete(transcript(1, 10), FINISH, "local-coder")
|
||||
client.complete(transcript(1, 10), None, "local-fast")
|
||||
assert [c["require_tool_call"] for c in inner.calls] == [True, False, False]
|
||||
|
||||
|
||||
def test_the_catalog_reads_supports_tool_choice() -> None:
|
||||
catalog = ModelCatalog.from_payload(
|
||||
{
|
||||
"data": [
|
||||
{"model_name": "a", "model_info": {"supports_tool_choice": True}},
|
||||
{"model_name": "b", "model_info": {"supports_tool_choice": False}},
|
||||
{"model_name": "c", "model_info": {}},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert [catalog.facts(m).supports_tool_choice for m in "abc"] == [True, False, None]
|
||||
|
||||
|
||||
def test_a_fallback_answer_is_recognised() -> None:
|
||||
facts = ModelCatalog.from_payload(
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"model_name": "claude-haiku-4-5",
|
||||
"litellm_params": {"model": "anthropic/claude-haiku-4-5"},
|
||||
}
|
||||
]
|
||||
}
|
||||
).facts("claude-haiku-4-5")
|
||||
assert facts.answered("claude-haiku-4-5") # LiteLLM's alias
|
||||
assert facts.answered("claude-haiku-4-5-20251001") # the provider's own name
|
||||
assert facts.answered(None) # nothing reported
|
||||
assert not facts.answered("claude-sonnet-5") # somebody else answered
|
||||
@@ -4,8 +4,8 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.cancel import STOPPED_RESULT, Cancel
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.cancel import STOPPED_RESULT, Cancel
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
DelegationCompleted,
|
||||
@@ -14,6 +14,10 @@ from nisse_dev.canonical import (
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.llm import BudgetExhausted, LLMError, UpstreamUnavailable
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state import Transcript
|
||||
from nisse_dev.delegation import (
|
||||
DELEGATE_TOOLS,
|
||||
DELEGATION_INTENT,
|
||||
@@ -22,11 +26,8 @@ from nisse_dev.delegation import (
|
||||
DelegationResult,
|
||||
delegation_reconciler,
|
||||
)
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""nisse-dev's state store: the shared one, with its own roles, views and
|
||||
build VM blocks (D3)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.canonical import ToolCall
|
||||
from nisse_core.state import StateStore as CoreStore
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Path:
|
||||
return tmp_path / "state.db"
|
||||
|
||||
|
||||
def test_a_vm_block_persists_across_reopen(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
store.block_vm("root@vm1", cause="broken", reason="pip crashes", work_item="x")
|
||||
store.block_vm("root@vm1", cause="broken", reason="later", work_item="y")
|
||||
with StateStore(db) as store:
|
||||
[block] = store.vm_blocks().values()
|
||||
assert (block.host, block.reason, block.work_item) == (
|
||||
"root@vm1",
|
||||
"pip crashes", # the first block is kept
|
||||
"x",
|
||||
)
|
||||
assert store.unblock_vm("root@vm1", cause="test") == block
|
||||
assert store.unblock_vm("root@vm1", cause="test") is None
|
||||
assert store.vm_blocks() == {}
|
||||
|
||||
|
||||
def test_a_version_4_store_gains_vm_blocks(db: Path) -> None:
|
||||
with StateStore(db, schema_version=4) as store:
|
||||
store._conn.execute("DROP TABLE vm_blocks") # as a real version 4 store
|
||||
with StateStore(db) as store:
|
||||
assert store.schema_version == 5
|
||||
store.block_vm("root@vm1", cause="broken", reason="r", work_item=None)
|
||||
assert list(store.vm_blocks()) == ["root@vm1"]
|
||||
|
||||
|
||||
def test_the_dev_roles_are_the_dev_stores_own(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
store.transcript("x", "toolchain")
|
||||
assert store.oplog.agent == "nisse-dev"
|
||||
core = CoreStore(db.with_name("core.db"))
|
||||
with core, pytest.raises(ValueError, match="unknown actor"):
|
||||
core.transcript("x", "toolchain")
|
||||
|
||||
|
||||
def test_each_role_is_shown_the_others_as_the_dev_store_says(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
for actor in ("driver", "toolchain"):
|
||||
store.transcript("x", actor).append(
|
||||
ToolCall(call_id=actor, tool="read_file", arguments={"path": "a"})
|
||||
)
|
||||
[seen] = store.transcript("x", "memory").view() # the driver's, told
|
||||
assert seen.actor == "driver" and "read_file(a)" in seen.event.text
|
||||
[own] = store.transcript("x", "driver").view() # not the toolchain's
|
||||
assert own.event.call_id == "driver"
|
||||
@@ -1,80 +0,0 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.canonical import Fact, PlanSubmitted, ToolCall, ToolResult, UserMessage
|
||||
from nisse_dev.dump import main, render
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
|
||||
def store_with_a_run(path: Path) -> None:
|
||||
store = StateStore(path, oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
store.transcript(None, "operator").append(
|
||||
UserMessage(text="hello before use", source="matrix")
|
||||
)
|
||||
store.open_work_item(
|
||||
"demo-1",
|
||||
title="Add a truncate helper",
|
||||
scope="demo",
|
||||
position="WAITING",
|
||||
data={"loop": {"wait": "go-ahead", "rev": 1}},
|
||||
cause="test",
|
||||
)
|
||||
store.set_active("demo-1", cause="test")
|
||||
log = store.transcript("demo-1", "operator")
|
||||
log.append(UserMessage(text="Add a truncate helper", source="matrix"))
|
||||
driver = log.as_actor("driver")
|
||||
driver.append(PlanSubmitted(plan="x" * 500, model="opus"))
|
||||
driver.append(
|
||||
ToolCall(call_id="c1", tool="run_command", arguments={"command": "ls"})
|
||||
)
|
||||
driver.append(ToolResult(call_id="c1", result="README.md", success=False))
|
||||
log.begin_intent("open_pr", {"branch": "b"}, cause="test")
|
||||
store.close()
|
||||
|
||||
|
||||
def test_the_dump_shows_items_intents_and_each_event_with_its_actor(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
store_with_a_run(db)
|
||||
text = render(db)
|
||||
assert "active work item: demo-1" in text
|
||||
assert (
|
||||
"demo-1 open position: WAITING scope: demo rev-1 waits for: go-ahead"
|
||||
in text
|
||||
)
|
||||
assert "pending intent #1 open_pr [demo-1]" in text
|
||||
assert "[agent/operator] USER [matrix]: hello before use" in text
|
||||
assert "[demo-1/operator] USER [matrix]: Add a truncate helper" in text
|
||||
assert "[demo-1/driver] PLAN (opus, 500 chars): " in text and "[340 more]" in text
|
||||
assert '[demo-1/driver] CALL run_command {"command": "ls"}' in text
|
||||
assert "result (FAILED): README.md" in text
|
||||
|
||||
|
||||
def test_full_shows_long_texts_whole(tmp_path: Path) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
store_with_a_run(db)
|
||||
assert "x" * 500 in render(db, full=True)
|
||||
|
||||
|
||||
def test_facts_are_shown_as_the_orchestrators(tmp_path: Path) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
store_with_a_run(db)
|
||||
store = StateStore(db, oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
store.transcript("demo-1", "orchestrator").append(Fact(text="PR #3 opened"))
|
||||
store.close()
|
||||
assert "[demo-1/orchestrator] FACT: PR #3 opened" in render(db)
|
||||
|
||||
|
||||
def test_the_dump_never_writes_to_the_store(tmp_path: Path) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
store_with_a_run(db)
|
||||
before = db.read_bytes()
|
||||
render(db)
|
||||
assert db.read_bytes() == before
|
||||
|
||||
|
||||
def test_a_missing_store_is_reported(tmp_path: Path, capsys) -> None:
|
||||
assert main([str(tmp_path / "nope.db")]) == 1
|
||||
assert "no state store" in capsys.readouterr().err
|
||||
@@ -4,13 +4,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
)
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_dev.evals import UNMARKED, collect, summarise, to_jsonl
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Running commands on a tool VM over SSH (L13), against a throwaway sshd."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import REDACTED, HostUnavailable, SSHHost, redact
|
||||
|
||||
|
||||
def test_exit_codes_and_streams_come_back(ssh_host: SSHHost) -> None:
|
||||
result = ssh_host.execute("echo out; echo err >&2; exit 3")
|
||||
assert (result.exit_code, result.stdout, result.stderr) == (3, "out\n", "err\n")
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_cwd_and_stdin(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
result = ssh_host.execute("pwd; cat", cwd=str(tmp_path), stdin="line one\nline two")
|
||||
assert result.stdout == f"{tmp_path}\nline one\nline two\n"
|
||||
|
||||
|
||||
def test_stdin_that_looks_like_a_delimiter_survives(ssh_host: SSHHost) -> None:
|
||||
payload = "NISSE_EOF\nEOF\n'quoted' $notexpanded `nor this`"
|
||||
assert ssh_host.execute("cat", stdin=payload).stdout == payload + "\n"
|
||||
|
||||
|
||||
def test_without_stdin_a_command_cannot_read_the_script(ssh_host: SSHHost) -> None:
|
||||
# The script arrives on the same channel; the command must see /dev/null.
|
||||
result = ssh_host.execute("cat; echo done")
|
||||
assert result.stdout == "done\n"
|
||||
|
||||
|
||||
def test_a_missing_cwd_fails_before_running(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
marker = tmp_path / "ran"
|
||||
result = ssh_host.execute(f"touch {marker}", cwd=str(tmp_path / "absent"))
|
||||
assert result.exit_code == 125
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_a_command_exiting_255_is_not_mistaken_for_ssh(ssh_host: SSHHost) -> None:
|
||||
assert ssh_host.execute("exit 255").exit_code == 254
|
||||
|
||||
|
||||
def test_an_unreachable_host_raises(tmp_path: Path) -> None:
|
||||
host = SSHHost("127.0.0.1", key=tmp_path / "k", state_dir=tmp_path / "s", port=1)
|
||||
with pytest.raises(HostUnavailable):
|
||||
host.execute("true")
|
||||
|
||||
|
||||
def test_a_timeout_is_reported(ssh_host: SSHHost) -> None:
|
||||
result = ssh_host.execute("sleep 30", timeout_s=1)
|
||||
assert result.timed_out
|
||||
|
||||
|
||||
def test_secrets_reach_the_command_and_never_its_output(ssh_host: SSHHost) -> None:
|
||||
value = "s-" + os.urandom(12).hex()
|
||||
result = ssh_host.execute(
|
||||
'echo "$API_KEY"; echo "$API_KEY" >&2', secrets={"API_KEY": value}
|
||||
)
|
||||
assert result.ok
|
||||
assert value not in result.stdout + result.stderr
|
||||
assert result.stdout == f"{REDACTED}\n"
|
||||
|
||||
|
||||
def test_secrets_are_never_on_a_command_line(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
# Made up here, so no command line in this test run can contain it except
|
||||
# by leaking it.
|
||||
value = "s-" + os.urandom(12).hex()
|
||||
seen = tmp_path / "ps.txt"
|
||||
ssh_host.execute(f"ps -eo args > {seen}", secrets={"API_KEY": value})
|
||||
assert "bash -s" in seen.read_text() # the listing did include our shell
|
||||
assert value not in seen.read_text()
|
||||
|
||||
|
||||
def test_redact_masks_longest_values_first() -> None:
|
||||
assert redact("abc-abcdef", ["abcdef", "abc-abcdef"]) == REDACTED
|
||||
assert redact("short ab stays", ["ab"]) == "short ab stays" # too short to mask
|
||||
|
||||
|
||||
def test_a_changed_host_key_is_its_own_failure_and_only_forgetting_clears_it(
|
||||
ssh_host: SSHHost, tmp_path: Path
|
||||
) -> None:
|
||||
import subprocess
|
||||
|
||||
from nisse_dev.execution import HostKeyChanged
|
||||
|
||||
host = SSHHost(
|
||||
ssh_host.address,
|
||||
key=ssh_host._key,
|
||||
user=ssh_host.user,
|
||||
port=ssh_host.port,
|
||||
state_dir=tmp_path / "state",
|
||||
)
|
||||
assert host.execute("true").ok # first contact pins the key
|
||||
host.close()
|
||||
# Someone else's key where the pinned one was: a rebuilt VM, or not it.
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(tmp_path / "other")],
|
||||
check=True,
|
||||
)
|
||||
other = (tmp_path / "other.pub").read_text().split()
|
||||
known = tmp_path / "state" / "known_hosts"
|
||||
name = host.address if host.port == 22 else f"[{host.address}]:{host.port}"
|
||||
known.write_text(f"{name} {other[0]} {other[1]}\n")
|
||||
with pytest.raises(HostKeyChanged):
|
||||
host.execute("true")
|
||||
host.forget_host_key()
|
||||
assert host.execute("true").ok # the real key pinned again
|
||||
@@ -4,13 +4,16 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
StoredEvent,
|
||||
)
|
||||
from nisse_core.llm import LLMError, RoleMap
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state import Transcript
|
||||
from nisse_dev.expert import (
|
||||
DEFAULT_BUDGET,
|
||||
BudgetExhausted,
|
||||
@@ -18,9 +21,7 @@ from nisse_dev.expert import (
|
||||
ExpertDesk,
|
||||
parse_answer,
|
||||
)
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
ROLES = RoleMap(
|
||||
driver="haiku",
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ class RecordingHost:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def execute(self, command: str, **kwargs):
|
||||
from nisse_dev.execution import Result
|
||||
from nisse_core.execution import Result
|
||||
|
||||
self.calls.append({"command": command, **kwargs})
|
||||
return Result(0, "", "")
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.gitea import (
|
||||
PR_PAGE_SIZE,
|
||||
Comment,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
pull_request_reconciler,
|
||||
)
|
||||
from nisse_dev.state import Intent
|
||||
|
||||
INTENT = Intent(
|
||||
id=1,
|
||||
kind="open_pr",
|
||||
payload={"branch": "milestone-1"},
|
||||
status="pending",
|
||||
created_at="t",
|
||||
resolved_at=None,
|
||||
)
|
||||
|
||||
|
||||
def client(handler) -> GiteaClient:
|
||||
return GiteaClient(
|
||||
"https://gitea.test/", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def pr_json(number: int, head: str, state: str = "open") -> dict:
|
||||
return {
|
||||
"number": number,
|
||||
"state": state,
|
||||
"head": {"ref": head},
|
||||
"html_url": f"https://gitea.test/o/r/pulls/{number}",
|
||||
}
|
||||
|
||||
|
||||
def test_open_pull_request_posts_expected_body() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(201, json=pr_json(7, "milestone-1"))
|
||||
|
||||
pr = client(handler).open_pull_request(
|
||||
"o/r", head="milestone-1", base="main", title="T", body="B"
|
||||
)
|
||||
assert seen["url"] == "https://gitea.test/api/v1/repos/o/r/pulls"
|
||||
assert seen["auth"] == "token tok"
|
||||
assert seen["body"] == {
|
||||
"head": "milestone-1",
|
||||
"base": "main",
|
||||
"title": "T",
|
||||
"body": "B",
|
||||
}
|
||||
assert pr.number == 7
|
||||
assert pr.head == "milestone-1"
|
||||
|
||||
|
||||
def test_find_pull_request_matches_head_branch() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[pr_json(3, "other")])
|
||||
assert client(handler).find_pull_request("o/r", "milestone-1") is None
|
||||
|
||||
|
||||
def test_find_pull_request_also_finds_closed() -> None:
|
||||
# A crash after opening must not be answered with "nothing open, open
|
||||
# another" — the effect already happened.
|
||||
handler = lambda request: httpx.Response(
|
||||
200, json=[pr_json(4, "milestone-1", "closed")]
|
||||
)
|
||||
found = client(handler).find_pull_request("o/r", "milestone-1")
|
||||
assert found is not None
|
||||
assert found.number == 4
|
||||
|
||||
|
||||
def test_find_pull_request_pages_past_the_first_page() -> None:
|
||||
# The match sits on page 2; sampling only the newest page would report
|
||||
# "absent" and licence a duplicate PR.
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
page = int(request.url.params.get("page", 1))
|
||||
if page == 1:
|
||||
return httpx.Response(
|
||||
200, json=[pr_json(i, f"other-{i}") for i in range(PR_PAGE_SIZE)]
|
||||
)
|
||||
return httpx.Response(200, json=[pr_json(999, "milestone-1", "closed")])
|
||||
|
||||
found = client(handler).find_pull_request("o/r", "milestone-1")
|
||||
assert found is not None
|
||||
assert found.number == 999
|
||||
|
||||
|
||||
def test_short_page_ends_the_search_definitively() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(int(request.url.params.get("page", 1)))
|
||||
return httpx.Response(200, json=[pr_json(1, "other")])
|
||||
|
||||
assert client(handler).find_pull_request("o/r", "milestone-1") is None
|
||||
assert calls == [1] # one short page proves absence; no further paging
|
||||
|
||||
|
||||
def test_unbounded_history_raises_rather_than_guessing() -> None:
|
||||
# Never answer "absent" from an incomplete search: the reconciler turns
|
||||
# this error into "unverifiable" and gates to the human.
|
||||
handler = lambda request: httpx.Response(
|
||||
200, json=[pr_json(i, f"other-{i}") for i in range(PR_PAGE_SIZE)]
|
||||
)
|
||||
with pytest.raises(GiteaError, match="without reaching the end"):
|
||||
client(handler).find_pull_request("o/r", "milestone-1")
|
||||
|
||||
|
||||
def test_error_status_raises_gitea_error() -> None:
|
||||
handler = lambda request: httpx.Response(403, text="forbidden")
|
||||
with pytest.raises(GiteaError, match="403"):
|
||||
client(handler).whoami()
|
||||
|
||||
|
||||
def test_list_and_post_comments() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "POST":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 9,
|
||||
"user": {"login": "don"},
|
||||
"body": "hi",
|
||||
"created_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 8,
|
||||
"user": {"login": "don"},
|
||||
"body": "approve rev-1",
|
||||
"created_at": "t",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
c = client(handler)
|
||||
assert c.list_comments("o/r", 1) == [
|
||||
Comment(id=8, author="don", body="approve rev-1", created_at="t")
|
||||
]
|
||||
assert c.post_comment("o/r", 1, "hi").id == 9
|
||||
|
||||
|
||||
def test_list_comments_passes_since() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["since"] = request.url.params.get("since")
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
client(handler).list_comments("o/r", 1, since="2026-09-07T00:00:00Z")
|
||||
assert seen["since"] == "2026-09-07T00:00:00Z"
|
||||
|
||||
|
||||
# --- reconciler (L8 external truth) --------------------------------------
|
||||
|
||||
|
||||
def test_reconciler_reports_existing_pr_as_done() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[pr_json(5, "milestone-1")])
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is True
|
||||
|
||||
|
||||
def test_reconciler_reports_missing_pr_as_not_done() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[])
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is False
|
||||
|
||||
|
||||
def test_unreachable_gitea_is_unverifiable_not_a_guess() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
# None gates to the human: a delayed decision is cheap, a duplicate PR
|
||||
# is not.
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is None
|
||||
|
||||
|
||||
def test_intent_without_a_branch_is_unverifiable() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[])
|
||||
blank = Intent(
|
||||
id=2,
|
||||
kind="open_pr",
|
||||
payload={},
|
||||
status="pending",
|
||||
created_at="t",
|
||||
resolved_at=None,
|
||||
)
|
||||
assert pull_request_reconciler(client(handler), "o/r")(blank) is None
|
||||
|
||||
|
||||
def test_closing_an_issue_and_a_pr() -> None:
|
||||
seen: list[tuple[str, str, dict]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content) if request.content else {}
|
||||
seen.append((request.method, request.url.path, body))
|
||||
if request.method == "POST":
|
||||
return httpx.Response(
|
||||
201, json={"id": 5, "user": {"login": "bot"}, "body": body["body"]}
|
||||
)
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
gitea = client(handler)
|
||||
gitea.post_comment("o/r", 10, "abandoned by don")
|
||||
gitea.close_issue("o/r", 10)
|
||||
gitea.post_comment("o/r", 20, "abandoned by don")
|
||||
gitea.close_pull_request("o/r", 20)
|
||||
|
||||
assert seen == [
|
||||
("POST", "/api/v1/repos/o/r/issues/10/comments", {"body": "abandoned by don"}),
|
||||
("PATCH", "/api/v1/repos/o/r/issues/10", {"state": "closed"}),
|
||||
("POST", "/api/v1/repos/o/r/issues/20/comments", {"body": "abandoned by don"}),
|
||||
("PATCH", "/api/v1/repos/o/r/pulls/20", {"state": "closed"}),
|
||||
]
|
||||
|
||||
|
||||
def test_assigned_at_is_the_last_assignment_across_pages() -> None:
|
||||
def assigned(login: str, at: str, removed: bool = False) -> dict:
|
||||
return {
|
||||
"type": "assignees",
|
||||
"assignee": {"login": login},
|
||||
"removed_assignee": removed,
|
||||
"created_at": at,
|
||||
}
|
||||
|
||||
filler = {"type": "comment", "created_at": "t0"}
|
||||
pages = {
|
||||
1: [assigned("dev-agent", "t1")] + [filler] * (PR_PAGE_SIZE - 1),
|
||||
2: [
|
||||
assigned("dev-agent", "t2", removed=True),
|
||||
assigned("someone", "t3"),
|
||||
assigned("dev-agent", "t4"),
|
||||
],
|
||||
}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/repos/o/r/issues/6/timeline"
|
||||
return httpx.Response(200, json=pages[int(request.url.params["page"])])
|
||||
|
||||
assert client(handler).assigned_at("o/r", 6, "dev-agent") == "t4"
|
||||
assert client(handler).assigned_at("o/r", 6, "nobody") is None
|
||||
|
||||
|
||||
def test_approvals_are_the_standing_approving_reviews() -> None:
|
||||
reviews = [
|
||||
{"state": "APPROVED", "user": {"login": "don"}, "commit_id": "abc"},
|
||||
{
|
||||
"state": "APPROVED",
|
||||
"user": {"login": "don"},
|
||||
"commit_id": "old",
|
||||
"stale": True,
|
||||
},
|
||||
{
|
||||
"state": "APPROVED",
|
||||
"user": {"login": "x"},
|
||||
"commit_id": "abc",
|
||||
"dismissed": True,
|
||||
},
|
||||
{"state": "REQUEST_CHANGES", "user": {"login": "don"}, "commit_id": "abc"},
|
||||
{"state": "COMMENT", "user": {"login": "don"}, "commit_id": "abc"},
|
||||
]
|
||||
handler = lambda request: httpx.Response(200, json=reviews)
|
||||
approvals = client(handler).approvals("o/r", 3)
|
||||
assert [(a.author, a.commit, a.stale) for a in approvals] == [
|
||||
("don", "abc", False),
|
||||
("don", "old", True),
|
||||
]
|
||||
|
||||
|
||||
def test_checks_are_the_combined_status_or_none() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/repos/o/r/commits/abc/status"
|
||||
return httpx.Response(200, json=answer)
|
||||
|
||||
answer: dict = {"state": "", "total_count": 0, "statuses": []}
|
||||
assert client(handler).checks("o/r", "abc") == "none"
|
||||
answer = {"state": "pending", "total_count": 1, "statuses": [{}]}
|
||||
assert client(handler).checks("o/r", "abc") == "pending"
|
||||
answer = {"state": "success", "total_count": 2, "statuses": [{}, {}]}
|
||||
assert client(handler).checks("o/r", "abc") == "success"
|
||||
|
||||
|
||||
def test_a_pull_request_carries_its_head_commit_and_mergeability() -> None:
|
||||
data = {
|
||||
**pr_json(5, "m-1"),
|
||||
"head": {"ref": "m-1", "sha": "f00"},
|
||||
"mergeable": True,
|
||||
}
|
||||
pr = client(lambda request: httpx.Response(200, json=data)).pull_request("o/r", 5)
|
||||
assert (pr.head_sha, pr.mergeable) == ("f00", True)
|
||||
|
||||
|
||||
def test_a_check_branch_is_made_through_the_api() -> None:
|
||||
seen: list = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append((request.method, request.url.path, request.content))
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
gitea = client(handler)
|
||||
gitea.branch_with_file(
|
||||
"o/r", base="main", branch="chk", path="f", text="x", message="m"
|
||||
)
|
||||
gitea.delete_branch("o/r", "chk")
|
||||
body = json.loads(seen[0][2])
|
||||
assert (seen[0][0], seen[0][1]) == ("POST", "/api/v1/repos/o/r/contents/f")
|
||||
assert (body["branch"], body["new_branch"], body["content"]) == (
|
||||
"main",
|
||||
"chk",
|
||||
"eA==",
|
||||
)
|
||||
assert seen[1][:2] == ("DELETE", "/api/v1/repos/o/r/branches/chk")
|
||||
@@ -1,516 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.envfile import load_env_file
|
||||
from nisse_dev.llm import (
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
OUTAGE_BACKOFF_S,
|
||||
RATE_LIMIT_RETRY_S,
|
||||
AnthropicPassthroughClient,
|
||||
BudgetExhausted,
|
||||
LiteLLMClient,
|
||||
LLMError,
|
||||
RoleMap,
|
||||
TruncatedResponse,
|
||||
UpstreamUnavailable,
|
||||
)
|
||||
|
||||
EVENTS = [StoredEvent(id=1, ts="t", event=UserMessage(text="hi", source="m:1"))]
|
||||
|
||||
|
||||
def make_client(handler, sleep=lambda _s: None) -> LiteLLMClient:
|
||||
return LiteLLMClient(
|
||||
"http://litellm.test/v1",
|
||||
"sk-test",
|
||||
transport=httpx.MockTransport(handler),
|
||||
sleep=sleep, # never wait in unit tests
|
||||
)
|
||||
|
||||
|
||||
def completion(message: dict) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200, json={"model": "gpt-test-1", "choices": [{"message": message}]}
|
||||
)
|
||||
|
||||
|
||||
def test_request_shape_and_auth() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["body"] = json.loads(request.content)
|
||||
return completion({"content": "hello"})
|
||||
|
||||
make_client(handler).complete(EVENTS, tools=None, model="local/qwen", system="s")
|
||||
assert seen["url"] == "http://litellm.test/v1/chat/completions"
|
||||
assert seen["auth"] == "Bearer sk-test"
|
||||
assert seen["body"]["model"] == "local/qwen"
|
||||
assert seen["body"]["messages"][0] == {"role": "system", "content": "s"}
|
||||
assert seen["body"]["messages"][1] == {"role": "user", "content": "hi"}
|
||||
|
||||
|
||||
def test_assistant_text_parsed_with_producing_model() -> None:
|
||||
client = make_client(lambda request: completion({"content": "hello"}))
|
||||
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert event == AssistantText(text="hello", model="gpt-test-1")
|
||||
|
||||
|
||||
def test_tool_calls_get_locally_minted_canonical_ids() -> None:
|
||||
message = {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_provider_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_tests",
|
||||
"arguments": '{"path": "tests/"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
client = make_client(lambda request: completion(message))
|
||||
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert isinstance(event, ToolCall)
|
||||
assert event.tool == "run_tests"
|
||||
assert event.arguments == {"path": "tests/"}
|
||||
assert event.call_id != "call_provider_abc" # provider ID never a join key
|
||||
assert event.call_id.startswith("call-")
|
||||
|
||||
|
||||
def test_truncated_tool_arguments_raise_llm_error() -> None:
|
||||
message = {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c",
|
||||
"type": "function",
|
||||
"function": {"name": "submit_plan", "arguments": '{"content": "'},
|
||||
}
|
||||
],
|
||||
}
|
||||
client = make_client(lambda request: completion(message))
|
||||
with pytest.raises(LLMError, match="non-JSON arguments"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
|
||||
|
||||
def test_non_object_tool_arguments_raise_llm_error() -> None:
|
||||
message = {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c",
|
||||
"type": "function",
|
||||
"function": {"name": "submit_plan", "arguments": '"just a string"'},
|
||||
}
|
||||
],
|
||||
}
|
||||
client = make_client(lambda request: completion(message))
|
||||
with pytest.raises(LLMError, match="expected object"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
|
||||
|
||||
def test_http_error_status_raises_llm_error() -> None:
|
||||
client = make_client(lambda request: httpx.Response(500, text="boom"))
|
||||
with pytest.raises(LLMError, match="500"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
|
||||
|
||||
def test_rate_limit_is_retried_once_then_surfaced() -> None:
|
||||
# A 429 is transient. Losing a long delegation to one costs far more
|
||||
# than waiting out the window, but retrying forever would hide a real
|
||||
# ceiling, so it is retried exactly once.
|
||||
attempts: list[int] = []
|
||||
waited: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts.append(1)
|
||||
return httpx.Response(429, text="rate limited")
|
||||
|
||||
client = make_client(handler, sleep=waited.append)
|
||||
with pytest.raises(LLMError, match="429"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert len(attempts) == 2
|
||||
assert waited == [RATE_LIMIT_RETRY_S]
|
||||
# Reported even though the call failed, so a caller can leave the wait
|
||||
# out of how long the model took.
|
||||
assert client.last_rate_limit_wait_s == RATE_LIMIT_RETRY_S
|
||||
|
||||
|
||||
def test_rate_limit_that_clears_succeeds() -> None:
|
||||
responses = [httpx.Response(429, text="slow down"), completion({"content": "hi"})]
|
||||
client = make_client(lambda request: responses.pop(0), sleep=lambda _s: None)
|
||||
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert isinstance(event, AssistantText)
|
||||
assert client.last_rate_limit_wait_s == RATE_LIMIT_RETRY_S
|
||||
|
||||
|
||||
def test_the_rate_limit_wait_is_per_call() -> None:
|
||||
responses = [
|
||||
httpx.Response(429, text="slow down"),
|
||||
completion({"content": "one"}),
|
||||
completion({"content": "two"}),
|
||||
]
|
||||
client = make_client(lambda request: responses.pop(0), sleep=lambda _s: None)
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert client.last_rate_limit_wait_s == 0.0
|
||||
|
||||
|
||||
def test_connection_failure_raises_llm_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
with pytest.raises(LLMError, match="request failed"):
|
||||
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
|
||||
|
||||
|
||||
def test_malformed_response_raises_llm_error() -> None:
|
||||
client = make_client(lambda request: httpx.Response(200, json={"oops": True}))
|
||||
with pytest.raises(LLMError, match="malformed"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
|
||||
|
||||
ROLES = RoleMap(
|
||||
driver="local/qwen",
|
||||
helper="anthropic/claude-sonnet-5",
|
||||
expert="anthropic/claude-opus-5",
|
||||
)
|
||||
|
||||
|
||||
def test_role_map_resolves_and_pins() -> None:
|
||||
roles = ROLES
|
||||
assert roles.resolve("driver") == "local/qwen"
|
||||
assert roles.pinned() == {
|
||||
"driver": "local/qwen",
|
||||
"helper": "anthropic/claude-sonnet-5",
|
||||
"expert": "anthropic/claude-opus-5",
|
||||
# Unset, the classifier falls back to the expert's model (L9).
|
||||
"classifier": "anthropic/claude-opus-5",
|
||||
}
|
||||
|
||||
|
||||
def test_a_pinned_map_from_before_the_driver_reads_its_own_roles() -> None:
|
||||
# Pinned before M2.8: the old loop's roles are there, and are ignored.
|
||||
old = {"planner": "p", "executor": "e", "driver": "d", "helper": "h"}
|
||||
roles = RoleMap.from_pinned(old, ROLES)
|
||||
assert (roles.driver, roles.helper, roles.expert) == ("d", "h", ROLES.expert)
|
||||
|
||||
|
||||
def test_unknown_role_raises() -> None:
|
||||
with pytest.raises(LLMError, match="unknown role"):
|
||||
ROLES.resolve("boss")
|
||||
|
||||
|
||||
def test_load_env_file(tmp_path: Path) -> None:
|
||||
envfile = tmp_path / ".env"
|
||||
envfile.write_text(
|
||||
"# comment\n\nNISSE_LLM_TOKEN=sk-abc\nNISSE_LLM_BASE_URL = http://x \n"
|
||||
)
|
||||
env: dict[str, str] = {"NISSE_LLM_TOKEN": "sk-already-set"}
|
||||
load_env_file(envfile, env)
|
||||
assert env["NISSE_LLM_TOKEN"] == "sk-already-set" # real env wins
|
||||
assert env["NISSE_LLM_BASE_URL"] == "http://x"
|
||||
|
||||
|
||||
def test_load_env_file_missing_is_noop(tmp_path: Path) -> None:
|
||||
env: dict[str, str] = {}
|
||||
load_env_file(tmp_path / "nope.env", env)
|
||||
assert env == {}
|
||||
|
||||
|
||||
# --- truncation (silent until measured against Opus) ---------------------
|
||||
|
||||
|
||||
def test_openai_route_raises_on_truncation() -> None:
|
||||
# finish_reason "length" means the answer was cut off; parsing it as
|
||||
# complete yields a tool call with required arguments simply absent.
|
||||
truncated = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "gpt-test-1",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "length",
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "submit_plan",
|
||||
"arguments": '{"type": "arch"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
client = make_client(lambda request: truncated)
|
||||
with pytest.raises(TruncatedResponse, match="output limit"):
|
||||
client.complete(EVENTS, tools=None, model="gpt-6-astra")
|
||||
|
||||
|
||||
def test_anthropic_route_raises_on_truncation() -> None:
|
||||
# Observed for real: Opus spent 4,064 output tokens against a 4,096
|
||||
# ceiling, and a longer plan came back as submit_plan with no content.
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "claude-opus-5",
|
||||
"stop_reason": "max_tokens",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "t",
|
||||
"name": "submit_plan",
|
||||
"input": {"type": "arch"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
client = AnthropicPassthroughClient(
|
||||
"https://litellm.test",
|
||||
"tok",
|
||||
transport=httpx.MockTransport(handler),
|
||||
sleep=lambda _s: None,
|
||||
)
|
||||
with pytest.raises(TruncatedResponse, match="output limit"):
|
||||
client.complete(EVENTS, tools=None, model="claude-opus-5")
|
||||
|
||||
|
||||
def test_a_complete_answer_is_not_mistaken_for_truncation() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "claude-opus-5",
|
||||
"stop_reason": "tool_use",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "t",
|
||||
"name": "submit_plan",
|
||||
"input": {"type": "arch", "content": "a real plan"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
client = AnthropicPassthroughClient(
|
||||
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
[call] = client.complete(EVENTS, tools=None, model="claude-opus-5")
|
||||
assert call.arguments["content"] == "a real plan"
|
||||
|
||||
|
||||
def test_output_ceiling_is_generous_by_default() -> None:
|
||||
# Output is billed by what is produced, so a low ceiling buys nothing and
|
||||
# costs silent truncation. The configured value is tested in test_config.
|
||||
assert DEFAULT_MAX_OUTPUT_TOKENS >= 8192
|
||||
assert LiteLLMClient("http://x", "t")._max_tokens == DEFAULT_MAX_OUTPUT_TOKENS
|
||||
|
||||
|
||||
def test_truncation_is_an_llm_error_so_callers_already_handle_it() -> None:
|
||||
# The planner retries, the executor and delegation gate. None of them
|
||||
# need to learn a new exception type.
|
||||
assert issubclass(TruncatedResponse, LLMError)
|
||||
|
||||
|
||||
def test_a_5xx_is_upstream_trouble() -> None:
|
||||
client = make_client(lambda request: httpx.Response(503, text="bad gateway"))
|
||||
with pytest.raises(UpstreamUnavailable, match="503"):
|
||||
client.complete(EVENTS, tools=None, model="local-fast")
|
||||
|
||||
|
||||
def test_a_connection_failure_is_upstream_trouble() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
with pytest.raises(UpstreamUnavailable, match="request failed"):
|
||||
make_client(handler).complete(EVENTS, tools=None, model="local-fast")
|
||||
|
||||
|
||||
def test_a_persistent_rate_limit_is_upstream_trouble() -> None:
|
||||
# It cleared for neither attempt, so the caller should keep its work
|
||||
# rather than discard it.
|
||||
client = make_client(lambda request: httpx.Response(429, text="slow down"))
|
||||
with pytest.raises(UpstreamUnavailable):
|
||||
client.complete(EVENTS, tools=None, model="local-fast")
|
||||
|
||||
|
||||
def test_a_bad_request_is_not_upstream_trouble() -> None:
|
||||
client = make_client(lambda request: httpx.Response(400, text="bad schema"))
|
||||
with pytest.raises(LLMError) as excinfo:
|
||||
client.complete(EVENTS, tools=None, model="local-fast")
|
||||
assert not isinstance(excinfo.value, UpstreamUnavailable)
|
||||
|
||||
|
||||
def test_budget_refusal_is_a_kind_of_upstream_trouble() -> None:
|
||||
# So one catch covers the family, but it can still be told apart where
|
||||
# the difference matters — it will not clear within the hour.
|
||||
assert issubclass(BudgetExhausted, UpstreamUnavailable)
|
||||
|
||||
|
||||
# --- tool_choice (L9: constrained decoding) --------------------------------
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish",
|
||||
"description": "done",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def capture_bodies() -> tuple[list[dict], Any]:
|
||||
bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
bodies.append(json.loads(request.content))
|
||||
return completion({"role": "assistant", "content": "ok"})
|
||||
|
||||
return bodies, handler
|
||||
|
||||
|
||||
def test_required_is_sent_only_when_asked_and_tools_are_offered() -> None:
|
||||
bodies, handler = capture_bodies()
|
||||
client = make_client(handler)
|
||||
client.complete(EVENTS, tools=TOOLS, model="m", require_tool_call=True)
|
||||
client.complete(EVENTS, tools=TOOLS, model="m")
|
||||
client.complete(EVENTS, tools=None, model="m", require_tool_call=True)
|
||||
assert bodies[0]["tool_choice"] == "required"
|
||||
assert "tool_choice" not in bodies[1] # the server's default, auto
|
||||
assert "tool_choice" not in bodies[2] # nothing to choose from
|
||||
|
||||
|
||||
def test_the_passthrough_asks_in_anthropics_terms() -> None:
|
||||
bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "claude-haiku-4-5",
|
||||
"stop_reason": "tool_use",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "t", "name": "finish", "input": {}}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
client = AnthropicPassthroughClient(
|
||||
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
client.complete(
|
||||
EVENTS, tools=TOOLS, model="claude-haiku-4-5", require_tool_call=True
|
||||
)
|
||||
assert bodies[0]["tool_choice"] == {"type": "any"}
|
||||
|
||||
|
||||
def test_the_answering_model_is_recorded() -> None:
|
||||
# A proxy fallback answers under another model's name; this is how a
|
||||
# caller can tell.
|
||||
client = make_client(
|
||||
lambda request: completion({"role": "assistant", "content": "hi"})
|
||||
)
|
||||
client.complete(EVENTS, tools=None, model="local-fast")
|
||||
assert client.last_model == "gpt-test-1"
|
||||
|
||||
|
||||
# --- outages delay, never fail (L5) --------------------------------------------
|
||||
|
||||
|
||||
def test_a_gateway_error_is_waited_out_with_backoff() -> None:
|
||||
responses = [
|
||||
httpx.Response(502, text="bad gateway"),
|
||||
httpx.Response(503, text="unavailable"),
|
||||
completion({"content": "back"}),
|
||||
]
|
||||
waited: list[float] = []
|
||||
client = make_client(lambda request: responses.pop(0), sleep=waited.append)
|
||||
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert isinstance(event, AssistantText) and event.text == "back"
|
||||
assert waited == list(OUTAGE_BACKOFF_S[:2])
|
||||
# Waiting isn't the model's time.
|
||||
assert client.last_rate_limit_wait_s == sum(OUTAGE_BACKOFF_S[:2])
|
||||
|
||||
|
||||
def test_an_outage_that_outlasts_the_backoff_is_surfaced() -> None:
|
||||
attempts: list[int] = []
|
||||
waited: list[float] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts.append(1)
|
||||
return httpx.Response(502, text="bad gateway")
|
||||
|
||||
client = make_client(handler, sleep=waited.append)
|
||||
with pytest.raises(UpstreamUnavailable, match="502"):
|
||||
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert len(attempts) == len(OUTAGE_BACKOFF_S) + 1
|
||||
assert waited == list(OUTAGE_BACKOFF_S)
|
||||
|
||||
|
||||
def test_a_dropped_connection_is_retried() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise httpx.ConnectError("refused")
|
||||
return completion({"content": "ok"})
|
||||
|
||||
[event] = make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert isinstance(event, AssistantText) and len(calls) == 2
|
||||
|
||||
|
||||
def test_a_500_or_a_timeout_is_not_retried() -> None:
|
||||
# A 500 may be the request's fault; a timeout already had its full
|
||||
# request_timeout_s.
|
||||
for failure in (
|
||||
lambda request: httpx.Response(500, text="boom"),
|
||||
lambda request: (_ for _ in ()).throw(httpx.ReadTimeout("slow")),
|
||||
):
|
||||
calls: list[int] = []
|
||||
|
||||
def handler(
|
||||
request: httpx.Request, failure=failure, calls=calls
|
||||
) -> httpx.Response:
|
||||
calls.append(1)
|
||||
return failure(request)
|
||||
|
||||
with pytest.raises(LLMError):
|
||||
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_a_budget_refusal_is_not_waited_on() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(1)
|
||||
return httpx.Response(503, text="Budget has been exceeded! Current cost: 5.1")
|
||||
|
||||
with pytest.raises(BudgetExhausted):
|
||||
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
|
||||
assert len(calls) == 1
|
||||
@@ -8,25 +8,25 @@ from typing import Any
|
||||
import pytest
|
||||
from model_contract import assert_renderable
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
Fact,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_dev.llm import LLMError
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_core.llm import LLMError
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import CLOSED, GO_AHEAD, READY, RUNNING, WAITING, loop_of
|
||||
from nisse_dev.loop.reader import command_form
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import ToolOutcome
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
DON = "@don:test"
|
||||
|
||||
@@ -250,7 +250,7 @@ def test_a_go_for_an_earlier_revision_is_not_applied(h: Harness) -> None:
|
||||
h.say("put it in its own module") # changes, so the driver restates rev-2
|
||||
assert loop_of(h.item()).rev == 2
|
||||
# A go sent while rev-1 was on screen, arriving now.
|
||||
from nisse_dev.transport import InboundMessage
|
||||
from nisse_core.transport import InboundMessage
|
||||
|
||||
h.agent.handle(InboundMessage(DON, "go", thread=h.thread()), about=1)
|
||||
assert not loop_of(h.item()).started
|
||||
|
||||
@@ -12,7 +12,7 @@ from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_core.actions import Action
|
||||
from nisse_dev.canonical import ActionVerdict, ToolCall, ToolResult
|
||||
from nisse_core.canonical import ActionVerdict, ToolCall, ToolResult
|
||||
from nisse_dev.loop.actions import classify, commits
|
||||
from nisse_dev.loop.items import ANSWER, loop_of
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_core.actions import Action
|
||||
from nisse_dev.canonical import ActionVerdict, ToolCall
|
||||
from nisse_dev.llm import LLMError
|
||||
from nisse_core.canonical import ActionVerdict, ToolCall
|
||||
from nisse_core.llm import LLMError
|
||||
from nisse_dev.loop.classifier import ActionClassifier, Scope
|
||||
from nisse_dev.loop.items import ANSWER, loop_of
|
||||
|
||||
@@ -312,7 +312,7 @@ def test_the_export_script_reads_a_store(tmp_path: Path) -> None:
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
|
||||
|
||||
@@ -9,8 +9,8 @@ from typing import Any
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, loop_agent
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import Fact, ToolResult
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_core.canonical import Fact, ToolResult
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_dev.loop.agent import FILE_ISSUE
|
||||
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, WAITING, loop_of
|
||||
|
||||
|
||||
@@ -18,16 +18,16 @@ from test_loop_milestones import (
|
||||
)
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_core.llm import RoleMap
|
||||
from nisse_dev.config import Profile
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Caps
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.loop.help import Helpers
|
||||
from nisse_dev.loop.items import ANSWER, MERGE, loop_of
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import pytest
|
||||
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
||||
from test_loop import h as h # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import Fact, ToolCall
|
||||
from nisse_dev.llm import LLMError
|
||||
from nisse_core.canonical import Fact, ToolCall
|
||||
from nisse_core.llm import LLMError
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, READY, WAITING, loop_of
|
||||
from nisse_dev.loop.reader import GoAheadReader
|
||||
|
||||
@@ -284,7 +284,7 @@ def test_an_unlabelled_issue_waits_for_go_with_no_countdown(h: Harness) -> None:
|
||||
|
||||
|
||||
def _msg(text: str, thread: str) -> Any:
|
||||
from nisse_dev.transport import InboundMessage
|
||||
from nisse_core.transport import InboundMessage
|
||||
|
||||
return InboundMessage(DON, text, thread=thread)
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ from test_loop_milestones import RESTATE, Forge, loop_agent, say
|
||||
from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import AssistantText, Fact
|
||||
from nisse_core.canonical import AssistantText, Fact
|
||||
from nisse_core.gitea import Comment
|
||||
from nisse_dev.delegation import Caps
|
||||
from nisse_dev.gitea import Comment
|
||||
from nisse_dev.loop.help import Helpers
|
||||
from nisse_dev.loop.items import GO_AHEAD, MERGE, WAITING, loop_of
|
||||
|
||||
@@ -231,7 +231,7 @@ def test_an_issue_assigned_again_after_its_item_closed_is_taken_again(
|
||||
|
||||
|
||||
def test_abandoning_a_blocked_issue_says_why_it_stays_open(tmp_path: Path) -> None:
|
||||
from nisse_dev.gitea import GiteaError
|
||||
from nisse_core.gitea import GiteaError
|
||||
|
||||
class Strict(FakeGitea):
|
||||
def close_issue(self, repo: str, index: int) -> None:
|
||||
|
||||
@@ -12,17 +12,17 @@ from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
from test_loop_notes import Spy
|
||||
|
||||
from nisse_dev.canonical import AssistantText, Fact
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.vault import Vault
|
||||
from nisse_core.canonical import AssistantText, Fact
|
||||
from nisse_core.llm import RoleMap
|
||||
from nisse_core.memory import Memory
|
||||
from nisse_core.vault import Vault
|
||||
|
||||
FOLDER = "memory/nisse-dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path: Path) -> Memory:
|
||||
return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}), FOLDER)
|
||||
return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}, agent="nisse-dev"), FOLDER)
|
||||
|
||||
|
||||
def facts(world: dict[str, Any]) -> list[str]:
|
||||
|
||||
@@ -10,11 +10,10 @@ from typing import Any
|
||||
import pytest
|
||||
from model_contract import assert_renderable
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import ToolCall
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.gitea import (
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.canonical import ToolCall
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.gitea import (
|
||||
Approval,
|
||||
Comment,
|
||||
GiteaError,
|
||||
@@ -22,15 +21,16 @@ from nisse_dev.gitea import (
|
||||
PullRequest,
|
||||
ReviewNote,
|
||||
)
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
|
||||
from nisse_dev.loop.milestones import OPEN_PR, Milestones
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
DON = "@don:test"
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ from test_loop_milestones import (
|
||||
)
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import ToolCall, ToolResult
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_core.canonical import ToolCall, ToolResult
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_dev.loop.items import MERGE, WAITING, loop_of
|
||||
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ import yaml
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, make_world
|
||||
from test_loop_projects import Router
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.config import _parse_projects
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, PROVISION, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
PROPOSAL = call(
|
||||
"propose_project",
|
||||
|
||||
@@ -11,9 +11,9 @@ from test_loop_milestones import DON, RESTATE, ScriptedModel, call, loop_agent
|
||||
from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import ToolResult
|
||||
from nisse_core.canonical import ToolResult
|
||||
from nisse_core.vault import Vault
|
||||
from nisse_dev.loop.items import GO_AHEAD, loop_of
|
||||
from nisse_dev.vault import Vault
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -21,7 +21,7 @@ def vault(tmp_path: Path) -> Vault:
|
||||
root = tmp_path / "vault"
|
||||
(root / "notes").mkdir(parents=True)
|
||||
(root / "notes" / "Style.md").write_text("# Style\n\nCount words with split.\n")
|
||||
return Vault(root, {"memory/nisse-dev": "rw", "notes": "ro"})
|
||||
return Vault(root, {"memory/nisse-dev": "rw", "notes": "ro"}, agent="nisse-dev")
|
||||
|
||||
|
||||
class Spy(ScriptedModel):
|
||||
|
||||
@@ -10,15 +10,15 @@ from typing import Any
|
||||
import pytest
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_world
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
REPOS = {"alpha": "don/alpha", "beta": "don/beta"}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ from test_loop_milestones import DON, Forge, call, make_world
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
from test_loop_projects import Router
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import (
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.canonical import (
|
||||
ActionVerdict,
|
||||
AssistantText,
|
||||
Fact,
|
||||
@@ -34,10 +34,15 @@ from nisse_dev.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.execution import HostUnavailable, SSHHost
|
||||
from nisse_core.gitea import Comment, Issue
|
||||
from nisse_core.llm import LLMError, RoleMap
|
||||
from nisse_core.memory import Memory
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InboundMessage, InMemoryTransport
|
||||
from nisse_core.vault import Vault, split_frontmatter
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Caps
|
||||
from nisse_dev.execution import HostUnavailable, SSHHost
|
||||
from nisse_dev.gitea import Comment, Issue
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.classifier import ActionClassifier
|
||||
from nisse_dev.loop.driver import Driver
|
||||
@@ -53,12 +58,7 @@ from nisse_dev.loop.items import (
|
||||
WAITING,
|
||||
loop_of,
|
||||
)
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
||||
from nisse_dev.vault import Vault, split_frontmatter
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
|
||||
# PR, from a seed that moves with the run number, so PRs still cover new
|
||||
@@ -360,7 +360,11 @@ class Walk:
|
||||
(vault_root / "notes").mkdir(parents=True, exist_ok=True)
|
||||
(vault_root / "notes" / "Ops.md").write_text("the operator's\n")
|
||||
self.memory = Memory(
|
||||
Vault(vault_root, {"memory/nisse-dev": "rw", "notes": "ro"}),
|
||||
Vault(
|
||||
vault_root,
|
||||
{"memory/nisse-dev": "rw", "notes": "ro"},
|
||||
agent="nisse-dev",
|
||||
),
|
||||
"memory/nisse-dev",
|
||||
)
|
||||
real = SSHHost.execute
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Any
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, loop_agent
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.gitea import Comment, Issue
|
||||
from nisse_core.gitea import Comment, Issue
|
||||
from nisse_core.transport import InboundMessage, InMemoryTransport
|
||||
from nisse_dev.loop.items import (
|
||||
ANSWER,
|
||||
BLOCKED,
|
||||
@@ -18,7 +19,6 @@ from nisse_dev.loop.items import (
|
||||
item_for_issue,
|
||||
loop_of,
|
||||
)
|
||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
||||
|
||||
|
||||
def two_issues(forge: Forge) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
||||
|
||||
from nisse_dev.canonical import AssistantText
|
||||
from nisse_core.canonical import AssistantText
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
|
||||
|
||||
|
||||
|
||||
@@ -26,18 +26,18 @@ from test_loop_milestones import (
|
||||
from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import Fact, ToolResult
|
||||
from nisse_dev.execution import HostKeyChanged
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.canonical import Fact, ToolResult
|
||||
from nisse_core.execution import HostKeyChanged
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.loop import preflight
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
PYTHON = {"name": "python", "version_command": "python3 --version"}
|
||||
|
||||
@@ -194,7 +194,7 @@ def test_what_was_installed_or_is_missing_is_said(
|
||||
def test_an_unfinished_step_is_said_and_work_goes_on(
|
||||
world: dict[str, Any], project: str
|
||||
) -> None:
|
||||
from nisse_dev.canonical import AssistantText
|
||||
from nisse_core.canonical import AssistantText
|
||||
|
||||
prose = [AssistantText(text="hmm", model="m")]
|
||||
model = ScriptedModel(prose, prose, RESTATE)
|
||||
|
||||
@@ -19,14 +19,14 @@ from test_loop_milestones import (
|
||||
from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_core.cancel import Cancel
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
from nisse_core.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import MERGE, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
CLOSE = [call("close_item", summary="added and merged")]
|
||||
|
||||
@@ -169,7 +169,7 @@ def test_unprotected_means_the_operator_merges(world: dict[str, Any]) -> None:
|
||||
def test_a_refused_merge_is_said_once_and_left_to_the_operator(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
from nisse_dev.gitea import GiteaError
|
||||
from nisse_core.gitea import GiteaError
|
||||
|
||||
forge: Forge = world["forge"]
|
||||
agent, transport = to_review(world)
|
||||
|
||||
@@ -19,9 +19,9 @@ from test_loop_toolchain import (
|
||||
)
|
||||
from test_loop_toolchain import project as project # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import ToolResult
|
||||
from nisse_dev.execution import HostKeyChanged, HostUnavailable
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_core.canonical import ToolResult
|
||||
from nisse_core.execution import HostKeyChanged, HostUnavailable
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_dev.loop.items import GO_AHEAD, READY, loop_of
|
||||
|
||||
RETRY, SETTLE = 300.0, 180.0
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import json
|
||||
import threading
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.matrix import MatrixConfig, MatrixTransport, TransportError
|
||||
from nisse_dev.transport import InboundMessage
|
||||
|
||||
CONFIG = MatrixConfig(
|
||||
homeserver="https://matrix.test",
|
||||
user_id="@nisse-dev:test",
|
||||
token="syt_secret",
|
||||
room_id="!room:test",
|
||||
)
|
||||
|
||||
|
||||
def sync_body(events: list[dict], next_batch: str) -> dict:
|
||||
return {
|
||||
"next_batch": next_batch,
|
||||
"rooms": {"join": {"!room:test": {"timeline": {"events": events}}}},
|
||||
}
|
||||
|
||||
|
||||
def message_event(sender: str, body: str) -> dict:
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": sender,
|
||||
"content": {"msgtype": "m.text", "body": body},
|
||||
}
|
||||
|
||||
|
||||
def test_send_message_puts_event_with_auth_and_unique_txn() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"event_id": "$e"})
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
transport.send_message("hello")
|
||||
transport.send_approval_request("plan rev-1")
|
||||
|
||||
first, second = seen
|
||||
assert first.headers["authorization"] == "Bearer syt_secret"
|
||||
assert "/rooms/!room:test/send/m.room.message/" in str(first.url)
|
||||
assert json.loads(first.content) == {"msgtype": "m.text", "body": "hello"}
|
||||
assert str(first.url) != str(second.url) # txn ids must be unique
|
||||
|
||||
|
||||
def test_send_failure_raises_transport_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, text="forbidden")
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
with pytest.raises(TransportError, match="403"):
|
||||
transport.send_message("hello")
|
||||
|
||||
|
||||
class SyncScript:
|
||||
"""Serves scripted /sync responses, then endless empty ones."""
|
||||
|
||||
def __init__(self, responses: list[dict]) -> None:
|
||||
self._responses = responses
|
||||
self._i = 0
|
||||
self.batches_seen: list[str | None] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path.endswith("/sync")
|
||||
self.batches_seen.append(request.url.params.get("since"))
|
||||
if self._i < len(self._responses):
|
||||
body = self._responses[self._i]
|
||||
self._i += 1
|
||||
return httpx.Response(200, json=body)
|
||||
return httpx.Response(200, json=sync_body([], f"b{self._i}"))
|
||||
|
||||
|
||||
def collect_messages(
|
||||
responses: list[dict], expected: int, timeout_s: float = 5.0
|
||||
) -> list[InboundMessage]:
|
||||
received: list[InboundMessage] = []
|
||||
done = threading.Event()
|
||||
|
||||
def on_message(msg: InboundMessage) -> None:
|
||||
received.append(msg)
|
||||
if len(received) >= expected:
|
||||
done.set()
|
||||
|
||||
transport = MatrixTransport(
|
||||
CONFIG,
|
||||
poll_timeout_ms=10,
|
||||
http_transport=httpx.MockTransport(SyncScript(responses)),
|
||||
)
|
||||
transport.on_message(on_message)
|
||||
transport.start()
|
||||
done.wait(timeout_s)
|
||||
transport.close()
|
||||
return received
|
||||
|
||||
|
||||
def test_listener_delivers_operator_messages() -> None:
|
||||
responses = [
|
||||
sync_body([message_event("@don:test", "backlog: never delivered")], "b1"),
|
||||
sync_body([message_event("@don:test", "approve")], "b2"),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="approve")]
|
||||
|
||||
|
||||
def test_own_and_non_message_events_are_filtered() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[
|
||||
message_event("@nisse-dev:test", "my own echo"),
|
||||
{"type": "m.room.member", "sender": "@don:test", "content": {}},
|
||||
message_event("@don:test", "status"),
|
||||
],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="status")]
|
||||
|
||||
|
||||
def test_first_sync_learns_position_without_delivering() -> None:
|
||||
script = SyncScript([sync_body([message_event("@don:test", "old")], "b1")])
|
||||
transport = MatrixTransport(
|
||||
CONFIG, poll_timeout_ms=10, http_transport=httpx.MockTransport(script)
|
||||
)
|
||||
got = threading.Event()
|
||||
transport.on_message(lambda msg: got.set())
|
||||
transport.start()
|
||||
assert not got.wait(0.3)
|
||||
transport.close()
|
||||
assert script.batches_seen[0] is None # initial sync carries no since
|
||||
assert "b1" in script.batches_seen # later polls resume from its batch
|
||||
|
||||
|
||||
def test_start_without_handler_raises() -> None:
|
||||
transport = MatrixTransport(
|
||||
CONFIG, http_transport=httpx.MockTransport(lambda r: httpx.Response(200))
|
||||
)
|
||||
with pytest.raises(TransportError, match="no message handler"):
|
||||
transport.start()
|
||||
|
||||
|
||||
# --- leading mentions --------------------------------------------------------
|
||||
|
||||
|
||||
def own_member_state(name: str) -> dict:
|
||||
"""The first sync's room state, which carries the agent's display name."""
|
||||
body = sync_body([], "b1")
|
||||
body["rooms"]["join"]["!room:test"]["state"] = {
|
||||
"events": [
|
||||
{
|
||||
"type": "m.room.member",
|
||||
"state_key": "@nisse-dev:test",
|
||||
"sender": "@nisse-dev:test",
|
||||
"content": {"membership": "join", "displayname": name},
|
||||
}
|
||||
]
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
def pill_event(sender: str, user_id: str, label: str, rest: str) -> dict:
|
||||
"""A message as Element sends one that starts by mentioning user_id."""
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": sender,
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": f"{label}: {rest}",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": (
|
||||
f'<a href="https://matrix.to/#/{user_id}">{label}</a>: {rest}'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_a_leading_pill_for_the_agent_is_removed_before_matching() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[pill_event("@don:test", "@nisse-dev:test", "Nisse Dev", "approve")],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.sender == "@don:test" # from the event, never from the text
|
||||
assert msg.text == "approve"
|
||||
assert msg.original == "Nisse Dev: approve"
|
||||
|
||||
|
||||
def test_the_agents_display_name_typed_in_front_is_removed() -> None:
|
||||
responses = [
|
||||
own_member_state("Nisse Dev"),
|
||||
sync_body([message_event("@don:test", "Nisse Dev: re-plan")], "b2"),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.text == "re-plan"
|
||||
|
||||
|
||||
def test_the_agents_user_id_typed_in_front_is_removed() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body([message_event("@don:test", "@nisse-dev:test: status")], "b2"),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.text == "status"
|
||||
|
||||
|
||||
def test_messages_addressed_to_someone_else_are_skipped() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[
|
||||
pill_event("@don:test", "@nisse-ops:test", "Nisse Ops", "approve"),
|
||||
message_event("@don:test", "@alice:test: looks good"),
|
||||
message_event("@don:test", "status"),
|
||||
],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="status")]
|
||||
|
||||
|
||||
def test_a_mention_later_in_the_message_is_left_alone() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body([message_event("@don:test", "approve, cc @alice:test")], "b2"),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [
|
||||
InboundMessage(sender="@don:test", text="approve, cc @alice:test")
|
||||
]
|
||||
|
||||
|
||||
# --- threads (L5): a work item's conversation is a thread --------------------
|
||||
|
||||
|
||||
def threaded_transport() -> tuple[MatrixTransport, list[dict], list[InboundMessage]]:
|
||||
sent: list[dict] = []
|
||||
ids = iter(f"$sent{n}" for n in range(1, 100))
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"event_id": next(ids)})
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
received: list[InboundMessage] = []
|
||||
transport.on_message(received.append)
|
||||
return transport, sent, received
|
||||
|
||||
|
||||
def event(event_id: str, body: str, relates: dict | None = None) -> dict:
|
||||
content: dict = {"msgtype": "m.text", "body": body}
|
||||
if relates is not None:
|
||||
content["m.relates_to"] = relates
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": "@don:test",
|
||||
"event_id": event_id,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def deliver(transport: MatrixTransport, *events: dict) -> None:
|
||||
transport._deliver(sync_body(list(events), "b"))
|
||||
|
||||
|
||||
def test_post_returns_the_event_id_and_threads_a_reply() -> None:
|
||||
transport, sent, _ = threaded_transport()
|
||||
root = transport.post("queued sandbox#14")
|
||||
transport.post("As I understand it: ...", thread=root)
|
||||
|
||||
assert root == "$sent1"
|
||||
assert "m.relates_to" not in sent[0]
|
||||
assert sent[1]["m.relates_to"] == {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$sent1",
|
||||
# Clients without threads show it as a reply to the root.
|
||||
"is_falling_back": True,
|
||||
"m.in_reply_to": {"event_id": "$sent1"},
|
||||
}
|
||||
|
||||
|
||||
def test_a_message_in_a_thread_carries_its_root() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$top", "status"),
|
||||
event("$in", "go", {"rel_type": "m.thread", "event_id": "$root"}),
|
||||
)
|
||||
assert [(m.text, m.event_id, m.thread) for m in received] == [
|
||||
("status", "$top", None),
|
||||
("go", "$in", "$root"),
|
||||
]
|
||||
|
||||
|
||||
def test_a_plain_reply_to_a_threaded_message_joins_its_thread() -> None:
|
||||
# Clients without thread support send an ordinary reply, quoting.
|
||||
transport, _, received = threaded_transport()
|
||||
root = transport.post("queued sandbox#14")
|
||||
in_thread = transport.post("As I understand it: ...", thread=root)
|
||||
deliver(
|
||||
transport,
|
||||
event(
|
||||
"$r1",
|
||||
"> <@nisse-dev:test> As I understand it: ...\n\nyes, go",
|
||||
{"m.in_reply_to": {"event_id": in_thread}},
|
||||
),
|
||||
event(
|
||||
"$r2",
|
||||
"> <@nisse-dev:test> queued sandbox#14\n\nthanks",
|
||||
{"m.in_reply_to": {"event_id": root}},
|
||||
),
|
||||
)
|
||||
assert [(m.text, m.thread) for m in received] == [
|
||||
("yes, go", root), # the quote is not what was said
|
||||
("thanks", root), # a reply to the root joins its thread
|
||||
]
|
||||
|
||||
|
||||
def test_a_reply_to_an_unknown_message_stays_at_top_level() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$r", "> <@x:test> old\n\nhm", {"m.in_reply_to": {"event_id": "$old"}}),
|
||||
)
|
||||
assert [(m.text, m.thread) for m in received] == [("hm", None)]
|
||||
|
||||
|
||||
def test_a_reply_to_the_operators_own_threaded_message_joins_the_thread() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$t", "first", {"rel_type": "m.thread", "event_id": "$root"}),
|
||||
event(
|
||||
"$r",
|
||||
"> <@don:test> first\n\nand also",
|
||||
{"m.in_reply_to": {"event_id": "$t"}},
|
||||
),
|
||||
)
|
||||
assert [m.thread for m in received] == ["$root", "$root"]
|
||||
|
||||
|
||||
def test_in_memory_transport_plays_threads() -> None:
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
transport = InMemoryTransport()
|
||||
seen: list[InboundMessage] = []
|
||||
transport.on_message(seen.append)
|
||||
root = transport.post("queued #14")
|
||||
transport.post("restated", thread=root)
|
||||
reply = transport.receive("@don:test", "go", thread=root)
|
||||
assert transport.outbound[-1].thread == root
|
||||
assert (seen[0].thread, seen[0].event_id) == (root, reply)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Agent memory (L6.4, M5.2): one note per fact in the agent's folder, an
|
||||
index the harness keeps, and a snapshot of it for the transcript."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.memory import Memory
|
||||
from nisse_dev.vault import Vault, VaultError, split_frontmatter
|
||||
|
||||
FOLDER = "memory/nisse-dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path: Path) -> Memory:
|
||||
return Memory(Vault(tmp_path / "v", {FOLDER: "rw", "notes": "ro"}), FOLDER)
|
||||
|
||||
|
||||
def note(memory: Memory, name: str) -> tuple[dict, str]:
|
||||
path = memory._vault.root / FOLDER / f"{name}.md"
|
||||
return split_frontmatter(path.read_text())
|
||||
|
||||
|
||||
def test_a_fact_is_a_note_with_its_description_and_provenance(memory: Memory) -> None:
|
||||
replaced = memory.remember(
|
||||
"prefers-small-prs",
|
||||
"The operator prefers small PRs",
|
||||
"Don said PRs over 400 lines are hard to review.\n",
|
||||
provenance={"source": "work item x"},
|
||||
)
|
||||
assert not replaced
|
||||
front, body = note(memory, "prefers-small-prs")
|
||||
assert front["description"] == "The operator prefers small PRs"
|
||||
assert front["provenance"]["source"] == "work item x"
|
||||
assert body == "Don said PRs over 400 lines are hard to review.\n"
|
||||
|
||||
|
||||
def test_the_same_name_replaces_and_the_index_follows(memory: Memory) -> None:
|
||||
memory.remember("b-fact", "Bee", "b", provenance={})
|
||||
memory.remember("a-fact", "Ay", "a", provenance={})
|
||||
assert memory.remember("a-fact", "Ay, again", "a2", provenance={})
|
||||
index = memory.snapshot().text
|
||||
assert index.endswith(
|
||||
"- [[memory/nisse-dev/a-fact]] — Ay, again\n- [[memory/nisse-dev/b-fact]] — Bee"
|
||||
)
|
||||
memory.forget("a-fact")
|
||||
assert "a-fact" not in memory.snapshot().text
|
||||
with pytest.raises(VaultError, match="no such note"):
|
||||
memory.forget("a-fact")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["index", "Caps", "../x", "a/b", "", "-x"])
|
||||
def test_names_are_plain(memory: Memory, name: str) -> None:
|
||||
with pytest.raises(VaultError, match="a note's name"):
|
||||
memory.remember(name, "d", "f", provenance={})
|
||||
|
||||
|
||||
def test_a_note_needs_a_description_and_a_fact(memory: Memory) -> None:
|
||||
with pytest.raises(VaultError, match="description and the fact"):
|
||||
memory.remember("x", " ", "f", provenance={})
|
||||
|
||||
|
||||
def test_the_snapshot_is_hashed_and_made_if_missing(memory: Memory) -> None:
|
||||
first = memory.snapshot()
|
||||
assert first.paths == (f"{FOLDER}/index.md",)
|
||||
assert "Nothing yet." in first.text
|
||||
assert memory.snapshot().sha == first.sha # same index, same hash
|
||||
memory.remember("x", "Ex", "f", provenance={})
|
||||
assert memory.snapshot().sha != first.sha
|
||||
|
||||
|
||||
def test_a_projects_notes_have_their_own_folder_and_index(memory: Memory) -> None:
|
||||
memory.remember("general-one", "General", "g", provenance={})
|
||||
memory.remember("deploy", "Deploys by tag", "d", provenance={}, project="sandbox")
|
||||
memory.remember("other", "Other's", "o", provenance={}, project="other")
|
||||
root = memory._vault.root / FOLDER
|
||||
assert (root / "projects" / "sandbox" / "deploy.md").exists()
|
||||
top = memory.snapshot().text
|
||||
assert "- [[memory/nisse-dev/general-one]] — General" in top
|
||||
assert "deploy" not in top.split("Projects:")[0]
|
||||
assert "- [[memory/nisse-dev/projects/sandbox/index]] — notes about sandbox" in top
|
||||
seen = memory.snapshot("sandbox")
|
||||
assert seen.paths == (
|
||||
f"{FOLDER}/index.md",
|
||||
f"{FOLDER}/projects/sandbox/index.md",
|
||||
)
|
||||
assert (
|
||||
"- [[memory/nisse-dev/projects/sandbox/deploy]] — Deploys by tag" in seen.text
|
||||
)
|
||||
assert "projects/other/other]]" not in seen.text
|
||||
memory.forget("deploy", "sandbox")
|
||||
assert memory.snapshot("sandbox").paths == (f"{FOLDER}/index.md",)
|
||||
assert "sandbox/index" not in memory.snapshot().text
|
||||
with pytest.raises(VaultError, match="not a project's name"):
|
||||
memory.remember("x", "d", "f", provenance={}, project="../x")
|
||||
@@ -1,183 +0,0 @@
|
||||
"""State-change log conventions (architecture L12): every durable mutation emits one
|
||||
JSON-lines event with the fixed envelope, from the store's own write path."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.reconcile import reconcile_on_boot
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
ENVELOPE_FIELDS = {"ts", "agent", "event", "task_id", "cause"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream() -> io.StringIO:
|
||||
return io.StringIO()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path, stream: io.StringIO) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", stream))
|
||||
|
||||
|
||||
def events(stream: io.StringIO) -> list[dict]:
|
||||
return [json.loads(line) for line in stream.getvalue().splitlines()]
|
||||
|
||||
|
||||
def start_item(
|
||||
store: StateStore, item_id: str, *, scope: str | None = None, cause: str
|
||||
) -> None:
|
||||
"""Open a work item, make it the active one and set it running."""
|
||||
store.open_work_item(
|
||||
item_id, title=item_id, scope=scope, position="IDLE", cause=cause
|
||||
)
|
||||
store.set_active(item_id, cause=cause)
|
||||
store.update_work_item(item_id, position="RUNNING", data={}, cause=cause)
|
||||
|
||||
|
||||
def test_every_event_carries_the_envelope(
|
||||
store: StateStore, stream: io.StringIO
|
||||
) -> None:
|
||||
start_item(store, "demo-1", cause="issue assigned")
|
||||
intent_id = store.begin_intent("push_branch", {}, cause="milestone execution")
|
||||
store.commit_intent(intent_id, cause="push verified")
|
||||
for event in events(stream):
|
||||
assert ENVELOPE_FIELDS <= event.keys()
|
||||
assert event["agent"] == "nisse-dev"
|
||||
|
||||
|
||||
def test_a_position_change_logs_from_to_and_cause(
|
||||
store: StateStore, stream: io.StringIO
|
||||
) -> None:
|
||||
start_item(store, "d-1", scope="d", cause="issue assigned")
|
||||
store.update_work_item("d-1", position="WAITING", data={}, cause="restated")
|
||||
# Opening the work item logs its own events first (work_item.open, .switch).
|
||||
first, second = [e for e in events(stream) if e["event"] == "state.transition"]
|
||||
assert (first["from"], first["to"]) == ("IDLE", "RUNNING")
|
||||
assert (second["from"], second["to"], second["project"]) == (
|
||||
"RUNNING",
|
||||
"WAITING",
|
||||
"d",
|
||||
)
|
||||
assert second["cause"] == "restated"
|
||||
|
||||
|
||||
def test_intent_lifecycle_events(store: StateStore, stream: io.StringIO) -> None:
|
||||
intent_id = store.begin_intent("push_branch", {"b": "m2"}, cause="milestone")
|
||||
store.abort_intent(intent_id, cause="discarded")
|
||||
begin, abort = events(stream)
|
||||
assert begin["event"] == "intent.begin"
|
||||
assert abort["event"] == "intent.abort"
|
||||
assert begin["intent_id"] == abort["intent_id"] == intent_id
|
||||
assert begin["kind"] == abort["kind"] == "push_branch"
|
||||
|
||||
|
||||
def test_task_id_threads_through_events(store: StateStore, stream: io.StringIO) -> None:
|
||||
store.open_work_item("general", title="general", position="IDLE", cause="boot")
|
||||
store.oplog.task_id = "task-42"
|
||||
start_item(store, "task-42", cause="issue assigned")
|
||||
before, *during = events(stream)
|
||||
assert before["task_id"] is None
|
||||
assert {e["task_id"] for e in during} == {"task-42"}
|
||||
|
||||
|
||||
def test_migration_emits_event(tmp_path: Path, stream: io.StringIO) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
with StateStore(db, schema_version=1):
|
||||
pass
|
||||
|
||||
def add_notes(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY)")
|
||||
|
||||
with StateStore(
|
||||
db,
|
||||
schema_version=2,
|
||||
migrations={1: add_notes},
|
||||
oplog=OpLog("nisse-dev", stream),
|
||||
):
|
||||
pass
|
||||
[event] = events(stream)
|
||||
assert event["event"] == "schema.migrate"
|
||||
assert (event["from_version"], event["to_version"]) == (1, 2)
|
||||
|
||||
|
||||
def test_boot_reconcile_emits_action_and_verdicts(
|
||||
store: StateStore, stream: io.StringIO
|
||||
) -> None:
|
||||
store.begin_intent("push_branch", {}, cause="milestone")
|
||||
reconcile_on_boot(store, {"push_branch": lambda intent: True})
|
||||
boot = [e for e in events(stream) if e["event"] == "boot.reconcile"]
|
||||
assert len(boot) == 1
|
||||
assert boot[0]["action"] == "resume"
|
||||
assert "effect verified" in boot[0]["verdicts"][0]
|
||||
commit = [e for e in events(stream) if e["event"] == "intent.commit"]
|
||||
assert commit[0]["cause"] == "reconciliation verdict: effect verified"
|
||||
|
||||
|
||||
def test_literal_log_lines_for_human_readability(
|
||||
store: StateStore, stream: io.StringIO, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Asserts the exact bytes written per line, so the on-disk format is
|
||||
visible here rather than only in a running system."""
|
||||
|
||||
class _FrozenDatetime:
|
||||
@staticmethod
|
||||
def now(tz: object) -> object:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime(2026, 9, 5, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
monkeypatch.setattr("nisse_dev.oplog.datetime", _FrozenDatetime)
|
||||
|
||||
start_item(store, "demo-m2", scope="demo", cause="issue assigned: demo#2")
|
||||
store.oplog.task_id = "demo-m2"
|
||||
intent_id = store.begin_intent(
|
||||
"push_branch", {"branch": "milestone-2"}, cause="milestone 2 execution"
|
||||
)
|
||||
store.commit_intent(intent_id, cause="push verified by Gitea query")
|
||||
|
||||
assert stream.getvalue().splitlines() == [
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "work_item.open", "task_id": null,'
|
||||
' "cause": "issue assigned: demo#2",'
|
||||
' "work_item": "demo-m2", "scope": "demo"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "work_item.switch", "task_id": null,'
|
||||
' "cause": "issue assigned: demo#2",'
|
||||
' "from": null, "to": "demo-m2"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "state.transition", "task_id": null,'
|
||||
' "cause": "issue assigned: demo#2",'
|
||||
' "from": "IDLE", "to": "RUNNING", "project": "demo"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "intent.begin", "task_id": "demo-m2",'
|
||||
' "cause": "milestone 2 execution", "intent_id": 1,'
|
||||
' "kind": "push_branch"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "intent.commit", "task_id": "demo-m2",'
|
||||
' "cause": "push verified by Gitea query",'
|
||||
' "intent_id": 1, "kind": "push_branch"}'
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_resolution_emits_nothing(
|
||||
store: StateStore, stream: io.StringIO
|
||||
) -> None:
|
||||
with pytest.raises(KeyError):
|
||||
store.commit_intent(9999, cause="test")
|
||||
assert events(stream) == []
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_core.execution import SSHHost
|
||||
from nisse_dev.loop import preflight
|
||||
from nisse_dev.loop.preflight import Tool
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from nisse_dev.probe import interpret, probe
|
||||
|
||||
|
||||
def test_a_cut_at_the_declared_window_matches() -> None:
|
||||
result = interpret(declared=4096, sent=6144, reported=4096, error=None)
|
||||
assert result.verdict == "matches"
|
||||
assert result.server_window == 4096
|
||||
|
||||
|
||||
def test_a_cut_below_the_declaration_says_what_to_set() -> None:
|
||||
# The dangerous case: prompts between the two would be silently cut.
|
||||
result = interpret(declared=8192, sent=12288, reported=4096, error=None)
|
||||
assert result.verdict == "declared_too_high"
|
||||
assert "set max_input_tokens to 4096" in result.message
|
||||
|
||||
|
||||
def test_a_cut_above_the_declaration_is_conservative() -> None:
|
||||
result = interpret(declared=4096, sent=6144, reported=5000, error=None)
|
||||
assert result.verdict == "declared_conservative"
|
||||
|
||||
|
||||
def test_an_uncut_prompt_means_the_server_allows_more() -> None:
|
||||
result = interpret(declared=4096, sent=6144, reported=6100, error=None)
|
||||
assert result.verdict == "declared_conservative"
|
||||
assert result.server_window is None
|
||||
|
||||
|
||||
def test_a_refusal_is_the_safe_behaviour() -> None:
|
||||
result = interpret(declared=4096, sent=6144, reported=None, error="400: too long")
|
||||
assert result.verdict == "refuses"
|
||||
|
||||
|
||||
def test_probe_calibrates_then_sends_half_again_the_window() -> None:
|
||||
"""Against a fake Ollama: 2 tokens a word, silently cut at 4096."""
|
||||
sizes: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
words = len(json.loads(request.content)["messages"][0]["content"].split())
|
||||
sizes.append(words * 2)
|
||||
reported = min(words * 2, 4096)
|
||||
return httpx.Response(
|
||||
200, json={"model": "llama3.2", "usage": {"prompt_tokens": reported}}
|
||||
)
|
||||
|
||||
with httpx.Client(
|
||||
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||
) as http:
|
||||
result = probe(http, "llama3.2", declared=4096)
|
||||
|
||||
assert result.verdict == "matches"
|
||||
assert sizes[1] >= 4096 * 1.4 # the probe really exceeded the window
|
||||
|
||||
|
||||
def test_a_fallback_answer_is_never_credited_to_the_model() -> None:
|
||||
"""Measured: LiteLLM answered an oversized prompt to local-coder with
|
||||
claude-sonnet-5, and the probe called the refusing server roomy."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
words = len(json.loads(request.content)["messages"][0]["content"].split())
|
||||
model = "local-coder" if words < 1000 else "claude-sonnet-5"
|
||||
return httpx.Response(
|
||||
200, json={"model": model, "usage": {"prompt_tokens": words * 2}}
|
||||
)
|
||||
|
||||
with httpx.Client(
|
||||
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||
) as http:
|
||||
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
|
||||
|
||||
assert result.verdict == "fallback"
|
||||
assert "claude-sonnet-5 answered instead of local-coder" in result.message
|
||||
|
||||
|
||||
def test_a_provider_reporting_its_own_model_name_is_not_a_fallback() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200, json={"model": "qwen3-14b", "usage": {"prompt_tokens": 400}}
|
||||
)
|
||||
|
||||
with httpx.Client(
|
||||
base_url="http://litellm", transport=httpx.MockTransport(handler)
|
||||
) as http:
|
||||
result = probe(http, "local-coder", declared=32768, backing="openai/qwen3-14b")
|
||||
|
||||
assert result.verdict != "fallback"
|
||||
@@ -1,68 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.reconcile import reconcile_on_boot
|
||||
from nisse_dev.state import Intent, StateStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db")
|
||||
|
||||
|
||||
def test_no_pending_intents_resumes(store: StateStore) -> None:
|
||||
report = reconcile_on_boot(store, {})
|
||||
assert report.action == "resume"
|
||||
assert report.notes == []
|
||||
|
||||
|
||||
def test_verified_effect_commits_intent_and_resumes(store: StateStore) -> None:
|
||||
store.begin_intent("push_branch", {"branch": "m2"}, cause="test")
|
||||
report = reconcile_on_boot(store, {"push_branch": lambda intent: True})
|
||||
assert report.action == "resume"
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_verified_no_effect_aborts_intent_and_resumes(store: StateStore) -> None:
|
||||
store.begin_intent("push_branch", {"branch": "m2"}, cause="test")
|
||||
report = reconcile_on_boot(store, {"push_branch": lambda intent: False})
|
||||
assert report.action == "resume"
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_unverifiable_intent_gates_to_await_input(store: StateStore) -> None:
|
||||
store.begin_intent("push_branch", {"branch": "m2"}, cause="test")
|
||||
report = reconcile_on_boot(store, {"push_branch": lambda intent: None})
|
||||
assert report.action == "await_input"
|
||||
assert [i.kind for i in store.pending_intents()] == ["push_branch"]
|
||||
|
||||
|
||||
def test_unknown_intent_kind_gates_to_await_input(store: StateStore) -> None:
|
||||
store.begin_intent("mystery", {}, cause="test")
|
||||
report = reconcile_on_boot(store, {})
|
||||
assert report.action == "await_input"
|
||||
assert "no reconciler registered" in report.notes[0]
|
||||
|
||||
|
||||
def test_reconciler_receives_the_intent(store: StateStore) -> None:
|
||||
store.begin_intent("push_branch", {"branch": "m2"}, cause="test")
|
||||
seen: list[Intent] = []
|
||||
|
||||
def check(intent: Intent) -> bool:
|
||||
seen.append(intent)
|
||||
return True
|
||||
|
||||
reconcile_on_boot(store, {"push_branch": check})
|
||||
assert seen[0].payload == {"branch": "m2"}
|
||||
|
||||
|
||||
def test_mixed_verdicts_still_gate(store: StateStore) -> None:
|
||||
store.begin_intent("push_branch", {}, cause="test")
|
||||
store.begin_intent("open_pr", {}, cause="test")
|
||||
report = reconcile_on_boot(
|
||||
store,
|
||||
{"push_branch": lambda intent: True, "open_pr": lambda intent: None},
|
||||
)
|
||||
assert report.action == "await_input"
|
||||
assert [i.kind for i in store.pending_intents()] == ["open_pr"]
|
||||
@@ -1,151 +0,0 @@
|
||||
import json
|
||||
from itertools import pairwise
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
GateOpened,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.render import OpenAIChatRenderer, effective_events
|
||||
|
||||
|
||||
def stored(events: list) -> list[StoredEvent]:
|
||||
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
||||
|
||||
|
||||
def test_renders_mixed_transcript_in_order() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="run the tests", source="m:1"),
|
||||
ToolCall(call_id="call-9", tool="run_tests", arguments={"path": "x"}),
|
||||
ToolResult(call_id="call-9", result="1 passed", success=True),
|
||||
AssistantText(text="all green", model="local/qwen"),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="local/qwen")
|
||||
assert body["model"] == "local/qwen"
|
||||
roles = [m["role"] for m in body["messages"]]
|
||||
assert roles == ["user", "assistant", "tool", "assistant"]
|
||||
call = body["messages"][1]["tool_calls"][0]
|
||||
assert call["id"] == "call-9"
|
||||
assert call["function"]["name"] == "run_tests"
|
||||
assert json.loads(call["function"]["arguments"]) == {"path": "x"}
|
||||
assert body["messages"][2]["tool_call_id"] == "call-9"
|
||||
assert "tools" not in body
|
||||
|
||||
|
||||
def test_system_prompt_and_tools_pass_through() -> None:
|
||||
tools = [{"type": "function", "function": {"name": "run_tests"}}]
|
||||
body = OpenAIChatRenderer().render(
|
||||
stored([UserMessage(text="hi", source="m:1")]),
|
||||
tools=tools,
|
||||
model="local/qwen",
|
||||
system="you are nisse-dev",
|
||||
)
|
||||
assert body["messages"][0] == {"role": "system", "content": "you are nisse-dev"}
|
||||
assert body["tools"] == tools
|
||||
|
||||
|
||||
def test_compaction_replaces_its_range_only() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="old-1", source="m:1"),
|
||||
AssistantText(text="old-2", model="q"),
|
||||
UserMessage(text="kept", source="m:3"),
|
||||
]
|
||||
)
|
||||
events.append(
|
||||
StoredEvent(
|
||||
id=4,
|
||||
ts="t",
|
||||
event=Compaction(summary="the old stuff", model="q", replaces=[1, 2]),
|
||||
)
|
||||
)
|
||||
remaining = effective_events(events)
|
||||
assert [e.id for e in remaining] == [3, 4]
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
contents = [m["content"] for m in body["messages"]]
|
||||
# Both render as user messages, so they share one: roles must alternate.
|
||||
assert contents == ["kept\n\n[summary of earlier conversation]\nthe old stuff"]
|
||||
|
||||
|
||||
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
|
||||
# Providers reject a dangling tool_use; this happens after a crash (L8)
|
||||
# or with turn-ending gate tools like submit_plan.
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="go", source="m:1"),
|
||||
ToolCall(call_id="call-x", tool="submit_plan", arguments={}),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert [m["role"] for m in body["messages"]] == ["user", "assistant", "tool"]
|
||||
assert body["messages"][2]["tool_call_id"] == "call-x"
|
||||
assert body["messages"][2]["content"] == "[no result recorded]"
|
||||
|
||||
|
||||
def test_answered_tool_call_gets_no_placeholder() -> None:
|
||||
events = stored(
|
||||
[
|
||||
ToolCall(call_id="call-x", tool="run_command", arguments={}),
|
||||
ToolResult(call_id="call-x", result="ok", success=True),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert [m["role"] for m in body["messages"]] == ["assistant", "tool"]
|
||||
assert body["messages"][1]["content"] == "ok"
|
||||
|
||||
|
||||
def test_gate_event_renders_as_context() -> None:
|
||||
events = stored([GateOpened(gate="arch", reason="plan submitted (rev-1)")])
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
assert "arch" in body["messages"][0]["content"]
|
||||
|
||||
|
||||
def _roles(body: dict) -> list[str]:
|
||||
return [m["role"] for m in body["messages"]]
|
||||
|
||||
|
||||
def test_one_turn_is_one_assistant_message() -> None:
|
||||
# Text and two parallel tool calls from one model turn: the OpenAI format
|
||||
# carries them as a single assistant message with a tool_calls list.
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="go", source="m:1"),
|
||||
AssistantText(text="Looking around first.", model="q"),
|
||||
ToolCall(call_id="a", tool="list_dir", arguments={"path": "."}),
|
||||
ToolCall(call_id="b", tool="read_file", arguments={"path": "README.md"}),
|
||||
ToolResult(call_id="a", result="README.md", success=True),
|
||||
ToolResult(call_id="b", result="hello", success=True),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert _roles(body) == ["user", "assistant", "tool", "tool"]
|
||||
turn = body["messages"][1]
|
||||
assert turn["content"] == "Looking around first."
|
||||
assert [c["id"] for c in turn["tool_calls"]] == ["a", "b"]
|
||||
|
||||
|
||||
def test_roles_never_repeat_for_strict_templates() -> None:
|
||||
"""Mistral's chat template raises on two user or two assistant messages
|
||||
in a row; every mix of events must render without one."""
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="one", source="m:1"),
|
||||
UserMessage(text="two", source="m:2"),
|
||||
AssistantText(text="thinking", model="q"),
|
||||
ToolCall(call_id="a", tool="list_dir", arguments={}),
|
||||
ToolResult(call_id="a", result="x", success=True),
|
||||
AssistantText(text="more", model="q"),
|
||||
AssistantText(text="and more", model="q"),
|
||||
ToolCall(call_id="b", tool="finish", arguments={}),
|
||||
]
|
||||
)
|
||||
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
|
||||
for first, second in pairwise(roles):
|
||||
assert not (first == second and first in ("user", "assistant")), roles
|
||||
@@ -1,196 +0,0 @@
|
||||
"""The Anthropic Messages shape differs structurally from the OpenAI one;
|
||||
these tests pin the differences that a normalising middlebox would blur."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.llm import AnthropicPassthroughClient, LLMError
|
||||
from nisse_dev.render_anthropic import (
|
||||
AnthropicMessagesRenderer,
|
||||
to_anthropic_tools,
|
||||
)
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "Run a shell command.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def stored(events: list) -> list[StoredEvent]:
|
||||
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
||||
|
||||
|
||||
def render(events: list, **kw) -> dict:
|
||||
return AnthropicMessagesRenderer().render(
|
||||
stored(events), kw.pop("tools", None), "claude-haiku-4-5", **kw
|
||||
)
|
||||
|
||||
|
||||
def test_system_is_a_top_level_field_not_a_message() -> None:
|
||||
body = render([UserMessage(text="hi", source="m")], system="you are nisse")
|
||||
assert body["system"] == "you are nisse"
|
||||
assert all(m["role"] != "system" for m in body["messages"])
|
||||
|
||||
|
||||
def test_max_tokens_is_always_present() -> None:
|
||||
# The Messages API rejects a request without it.
|
||||
assert render([UserMessage(text="hi", source="m")])["max_tokens"] > 0
|
||||
|
||||
|
||||
def test_tool_call_is_a_content_block_not_an_array() -> None:
|
||||
body = render(
|
||||
[ToolCall(call_id="call-1", tool="run_command", arguments={"command": "ls"})]
|
||||
)
|
||||
block = body["messages"][0]["content"][0]
|
||||
assert block["type"] == "tool_use"
|
||||
assert block["id"] == "call-1"
|
||||
assert block["input"] == {"command": "ls"}
|
||||
assert "tool_calls" not in body["messages"][0]
|
||||
|
||||
|
||||
def test_tool_result_is_a_user_block_directly_after_the_call() -> None:
|
||||
body = render(
|
||||
[
|
||||
ToolCall(call_id="call-1", tool="run_command", arguments={}),
|
||||
ToolResult(call_id="call-1", result="exit code: 0", success=True),
|
||||
]
|
||||
)
|
||||
assistant, user = body["messages"]
|
||||
assert assistant["role"] == "assistant"
|
||||
assert assistant["content"][0]["type"] == "tool_use"
|
||||
assert user["role"] == "user" # not a "tool" role, as OpenAI would have
|
||||
assert user["content"][0] == {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call-1",
|
||||
"content": "exit code: 0",
|
||||
}
|
||||
|
||||
|
||||
def test_adjacent_same_role_events_are_merged() -> None:
|
||||
body = render(
|
||||
[
|
||||
UserMessage(text="one", source="m"),
|
||||
UserMessage(text="two", source="m"),
|
||||
AssistantText(text="reply", model="m"),
|
||||
]
|
||||
)
|
||||
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
|
||||
assert [b["text"] for b in body["messages"][0]["content"]] == ["one", "two"]
|
||||
|
||||
|
||||
def test_dangling_tool_call_gets_a_placeholder_result() -> None:
|
||||
body = render([ToolCall(call_id="call-x", tool="submit_plan", arguments={})])
|
||||
assert [m["role"] for m in body["messages"]] == ["assistant", "user"]
|
||||
assert body["messages"][1]["content"][0]["tool_use_id"] == "call-x"
|
||||
|
||||
|
||||
def test_tools_are_converted_to_input_schema() -> None:
|
||||
[tool] = to_anthropic_tools(TOOLS)
|
||||
assert tool["name"] == "run_command"
|
||||
assert tool["input_schema"]["properties"] == {"command": {"type": "string"}}
|
||||
assert "parameters" not in tool
|
||||
assert "function" not in tool
|
||||
|
||||
|
||||
def test_compaction_still_replaces_its_range() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="old", source="m"),
|
||||
UserMessage(text="kept", source="m"),
|
||||
]
|
||||
)
|
||||
events.append(
|
||||
StoredEvent(
|
||||
id=3,
|
||||
ts="t",
|
||||
event=Compaction(summary="summary", model="m", replaces=[1, 1]),
|
||||
)
|
||||
)
|
||||
body = AnthropicMessagesRenderer().render(events, None, "claude-haiku-4-5")
|
||||
texts = [b["text"] for m in body["messages"] for b in m["content"]]
|
||||
assert "old" not in texts
|
||||
assert any("summary" in t for t in texts)
|
||||
|
||||
|
||||
# --- passthrough client --------------------------------------------------
|
||||
|
||||
|
||||
def client(handler) -> AnthropicPassthroughClient:
|
||||
return AnthropicPassthroughClient(
|
||||
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def test_client_posts_to_the_messages_route_with_version_header() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["version"] = request.headers.get("anthropic-version")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
},
|
||||
)
|
||||
|
||||
events = stored([UserMessage(text="hello", source="m")])
|
||||
[event] = client(handler).complete(events, None, "claude-haiku-4-5")
|
||||
assert seen["url"] == "https://litellm.test/v1/messages"
|
||||
assert seen["version"] == "2023-06-01"
|
||||
assert event == AssistantText(text="hi", model="claude-haiku-4-5")
|
||||
|
||||
|
||||
def test_client_maps_tool_use_to_local_call_ids() -> None:
|
||||
response = {
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "running it"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_vendor_xyz",
|
||||
"name": "run_command",
|
||||
"input": {"command": "pytest"},
|
||||
},
|
||||
],
|
||||
}
|
||||
events = stored([UserMessage(text="go", source="m")])
|
||||
text, call = client(lambda r: httpx.Response(200, json=response)).complete(
|
||||
events, TOOLS, "claude-haiku-4-5"
|
||||
)
|
||||
assert isinstance(text, AssistantText)
|
||||
assert isinstance(call, ToolCall)
|
||||
assert call.arguments == {"command": "pytest"}
|
||||
assert call.call_id != "toolu_vendor_xyz" # provider id never a join key
|
||||
assert call.call_id.startswith("call-")
|
||||
|
||||
|
||||
def test_client_errors_are_llm_errors() -> None:
|
||||
events = stored([UserMessage(text="go", source="m")])
|
||||
with pytest.raises(LLMError, match="400"):
|
||||
client(lambda r: httpx.Response(400, text="bad")).complete(
|
||||
events, None, "claude-haiku-4-5"
|
||||
)
|
||||
with pytest.raises(LLMError, match="malformed"):
|
||||
client(lambda r: httpx.Response(200, json={"oops": 1})).complete(
|
||||
events, None, "claude-haiku-4-5"
|
||||
)
|
||||
@@ -1,148 +0,0 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.state import StateSchemaError, StateStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Path:
|
||||
return tmp_path / "state.db"
|
||||
|
||||
|
||||
def test_wal_mode_and_full_sync(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
journal = store._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
sync = store._conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
assert journal == "wal"
|
||||
assert sync == 2 # FULL
|
||||
|
||||
|
||||
def test_fresh_store_stamps_schema_version(db: Path) -> None:
|
||||
with StateStore(db, schema_version=1) as store:
|
||||
assert store.schema_version == 1
|
||||
|
||||
|
||||
def test_a_work_item_persists_across_reopen(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
store.open_work_item(
|
||||
"demo-1", title="t", scope="demo", position="WAITING", cause="test"
|
||||
)
|
||||
store.set_active("demo-1", cause="test")
|
||||
store.update_work_item(
|
||||
"demo-1", position="RUNNING", data={"milestone": 2}, cause="test"
|
||||
)
|
||||
with StateStore(db) as store:
|
||||
item = store.active_item()
|
||||
assert item is not None
|
||||
assert (item.id, item.scope, item.position) == ("demo-1", "demo", "RUNNING")
|
||||
assert item.data == {"milestone": 2}
|
||||
|
||||
|
||||
def test_a_fresh_store_has_no_active_item(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
assert store.active_item() is None
|
||||
|
||||
|
||||
def test_intent_lifecycle(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
intent_id = store.begin_intent("push_branch", {"branch": "m2"}, cause="test")
|
||||
[pending] = store.pending_intents()
|
||||
assert pending.id == intent_id
|
||||
assert pending.kind == "push_branch"
|
||||
assert pending.payload == {"branch": "m2"}
|
||||
store.commit_intent(intent_id, cause="test")
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_pending_intent_survives_reopen(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
store.begin_intent("open_pr", {"title": "hello"}, cause="test")
|
||||
with StateStore(db) as store:
|
||||
[pending] = store.pending_intents()
|
||||
assert pending.kind == "open_pr"
|
||||
|
||||
|
||||
def test_aborted_intent_is_not_pending(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
intent_id = store.begin_intent("push_branch", {}, cause="test")
|
||||
store.abort_intent(intent_id, cause="test")
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_resolving_nonpending_intent_raises(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
intent_id = store.begin_intent("push_branch", {}, cause="test")
|
||||
store.commit_intent(intent_id, cause="test")
|
||||
with pytest.raises(KeyError):
|
||||
store.commit_intent(intent_id, cause="test")
|
||||
with pytest.raises(KeyError):
|
||||
store.abort_intent(9999, cause="test")
|
||||
|
||||
|
||||
def test_migration_runs_and_bumps_version(db: Path) -> None:
|
||||
with StateStore(db, schema_version=1):
|
||||
pass
|
||||
|
||||
def add_notes_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)")
|
||||
|
||||
with StateStore(db, schema_version=2, migrations={1: add_notes_table}) as store:
|
||||
assert store.schema_version == 2
|
||||
store._conn.execute("SELECT * FROM notes")
|
||||
|
||||
|
||||
def test_missing_migration_raises(db: Path) -> None:
|
||||
with StateStore(db, schema_version=1):
|
||||
pass
|
||||
with pytest.raises(StateSchemaError, match="no migration"):
|
||||
StateStore(db, schema_version=2, migrations={})
|
||||
|
||||
|
||||
def test_failed_migration_leaves_version_unchanged(db: Path) -> None:
|
||||
with StateStore(db, schema_version=1):
|
||||
pass
|
||||
|
||||
def broken(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY)")
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
StateStore(db, schema_version=2, migrations={1: broken})
|
||||
with StateStore(db, schema_version=1) as store:
|
||||
assert store.schema_version == 1
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
store._conn.execute("SELECT * FROM notes")
|
||||
|
||||
|
||||
def test_newer_schema_is_refused(db: Path) -> None:
|
||||
with StateStore(db, schema_version=3):
|
||||
pass
|
||||
with pytest.raises(StateSchemaError, match="newer"):
|
||||
StateStore(db, schema_version=1)
|
||||
|
||||
|
||||
def test_a_vm_block_persists_across_reopen(db: Path) -> None:
|
||||
with StateStore(db) as store:
|
||||
store.block_vm("root@vm1", cause="broken", reason="pip crashes", work_item="x")
|
||||
store.block_vm("root@vm1", cause="broken", reason="later", work_item="y")
|
||||
with StateStore(db) as store:
|
||||
[block] = store.vm_blocks().values()
|
||||
assert (block.host, block.reason, block.work_item) == (
|
||||
"root@vm1",
|
||||
"pip crashes", # the first block is kept
|
||||
"x",
|
||||
)
|
||||
assert store.unblock_vm("root@vm1", cause="test") == block
|
||||
assert store.unblock_vm("root@vm1", cause="test") is None
|
||||
assert store.vm_blocks() == {}
|
||||
|
||||
|
||||
def test_a_version_4_store_gains_vm_blocks(db: Path) -> None:
|
||||
with StateStore(db, schema_version=4) as store:
|
||||
store._conn.execute("DROP TABLE vm_blocks") # as a real version 4 store
|
||||
with StateStore(db) as store:
|
||||
assert store.schema_version == 5
|
||||
store.block_vm("root@vm1", cause="broken", reason="r", work_item=None)
|
||||
assert list(store.vm_blocks()) == ["root@vm1"]
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import REDACTED, SSHHost
|
||||
from nisse_core.execution import REDACTED, SSHHost
|
||||
from nisse_dev.tools import EXECUTION_TOOLS, MAX_OUTPUT_CHARS, Workspace
|
||||
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""The knowledge vault (L10, M5.1): only configured folders exist, paths
|
||||
can't escape them, read-only folders refuse writes, writes are atomic and
|
||||
record provenance, and the editor's own files are never notes."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from nisse_dev import vault as vault_module
|
||||
from nisse_dev.vault import Vault, VaultError, split_frontmatter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path: Path) -> Vault:
|
||||
root = tmp_path / "vault"
|
||||
(root / "notes" / "deep").mkdir(parents=True)
|
||||
(root / "notes" / "Procedures.md").write_text("# Procedures\n\nRestart the hub.\n")
|
||||
(root / "notes" / "deep" / "Hub.md").write_text(
|
||||
"The hub is in the shed.\nhub hub\n"
|
||||
)
|
||||
(root / "notes" / ".silverbullet").mkdir()
|
||||
(root / "notes" / ".silverbullet" / "state.md").write_text("hub")
|
||||
(root / "notes" / "_plug").mkdir()
|
||||
(root / "notes" / "_plug" / "x.md").write_text("hub")
|
||||
(root / "private").mkdir()
|
||||
(root / "private" / "Secret.md").write_text("the hub password")
|
||||
(root / "memory" / "nisse-ops").mkdir(parents=True)
|
||||
(root / "memory" / "nisse-ops" / "Ops.md").write_text("ops hub")
|
||||
return Vault(
|
||||
root,
|
||||
{"memory/nisse-dev": "rw", "notes": "ro"},
|
||||
clock=lambda: datetime(2026, 10, 8, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_only_configured_folders_exist(vault: Vault) -> None:
|
||||
assert vault.read("notes/Procedures.md").startswith("# Procedures")
|
||||
for path in ("private/Secret.md", "memory/nisse-ops/Ops.md", "Top.md"):
|
||||
with pytest.raises(VaultError, match="not in a folder you can see"):
|
||||
vault.read(path)
|
||||
assert vault.notes() == ["notes/Procedures.md", "notes/deep/Hub.md"]
|
||||
assert vault.notes("notes/deep") == ["notes/deep/Hub.md"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/etc/passwd", "notes/../private/Secret.md", "", "notes\\x.md", "../x.md"],
|
||||
)
|
||||
def test_paths_cant_escape(vault: Vault, path: str) -> None:
|
||||
with pytest.raises(VaultError):
|
||||
vault.read(path)
|
||||
|
||||
|
||||
def test_a_link_out_of_its_folder_is_refused(vault: Vault) -> None:
|
||||
os.symlink(vault.root / "private" / "Secret.md", vault.root / "notes" / "Ln.md")
|
||||
with pytest.raises(VaultError, match="leads outside its folder"):
|
||||
vault.read("notes/Ln.md")
|
||||
assert "notes/Ln.md" not in vault.notes()
|
||||
assert all(f.path != "notes/Ln.md" for f in vault.search("password"))
|
||||
|
||||
|
||||
def test_the_editors_files_are_not_notes(vault: Vault) -> None:
|
||||
with pytest.raises(VaultError, match="editor's own files"):
|
||||
vault.read("notes/.silverbullet/state.md")
|
||||
with pytest.raises(VaultError, match="editor's own files"):
|
||||
vault.read("notes/_plug/x.md")
|
||||
with pytest.raises(VaultError, match="Markdown"):
|
||||
vault.read("notes/deep")
|
||||
|
||||
|
||||
def test_search_ranks_matches_and_sees_only_what_it_may(vault: Vault) -> None:
|
||||
found = vault.search("HUB")
|
||||
assert [f.path for f in found] == ["notes/deep/Hub.md", "notes/Procedures.md"]
|
||||
assert found[0].lines == ("The hub is in the shed.", "hub hub")
|
||||
with pytest.raises(VaultError):
|
||||
vault.search(" ")
|
||||
|
||||
|
||||
def test_a_long_note_is_cut_and_says_so(vault: Vault) -> None:
|
||||
(vault.root / "notes" / "Long.md").write_text("x" * 30_000)
|
||||
text = vault.read("notes/Long.md")
|
||||
assert text.endswith("[10000 more characters not shown]")
|
||||
|
||||
|
||||
def test_read_only_folders_refuse_writes(vault: Vault) -> None:
|
||||
with pytest.raises(VaultError, match="read-only"):
|
||||
vault.write("notes/New.md", "hi", provenance={"source": "t"})
|
||||
assert not (vault.root / "notes" / "New.md").exists()
|
||||
|
||||
|
||||
def test_a_write_records_provenance_and_keeps_the_rest(vault: Vault) -> None:
|
||||
text = "---\ntags: [hub]\naliases: [h]\n---\n# Hub\n\nIn the shed.\n"
|
||||
vault.write(
|
||||
"memory/nisse-dev/sub/Hub.md",
|
||||
text,
|
||||
provenance={"source": "matrix", "work_item": "sandbox-14"},
|
||||
)
|
||||
written = (vault.root / "memory" / "nisse-dev" / "sub" / "Hub.md").read_text()
|
||||
front, body = split_frontmatter(written)
|
||||
assert front["tags"] == ["hub"] and front["aliases"] == ["h"]
|
||||
assert front["provenance"] == {
|
||||
"by": "nisse-dev",
|
||||
"source": "matrix",
|
||||
"work_item": "sandbox-14",
|
||||
"written": "2026-10-08T12:00:00+00:00",
|
||||
}
|
||||
assert body == "# Hub\n\nIn the shed.\n"
|
||||
assert vault.notes("memory/nisse-dev") == ["memory/nisse-dev/sub/Hub.md"]
|
||||
|
||||
|
||||
def test_an_interrupted_write_leaves_the_old_note_and_no_litter(
|
||||
vault: Vault, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
vault.write("memory/nisse-dev/N.md", "old\n", provenance={"source": "t"})
|
||||
folder = vault.root / "memory" / "nisse-dev"
|
||||
before = (folder / "N.md").read_text()
|
||||
|
||||
def crash(src: str, dst: str) -> None:
|
||||
raise OSError("disk gone")
|
||||
|
||||
monkeypatch.setattr(vault_module.os, "replace", crash)
|
||||
with pytest.raises(VaultError, match="couldn't write it"):
|
||||
vault.write("memory/nisse-dev/N.md", "new\n", provenance={"source": "t"})
|
||||
assert (folder / "N.md").read_text() == before
|
||||
assert sorted(p.name for p in folder.iterdir()) == ["N.md"]
|
||||
|
||||
|
||||
def test_bad_frontmatter_is_refused(vault: Vault) -> None:
|
||||
with pytest.raises(VaultError, match="valid YAML"):
|
||||
vault.write(
|
||||
"memory/nisse-dev/B.md", "---\n: [\n---\nx\n", provenance={"source": "t"}
|
||||
)
|
||||
with pytest.raises(VaultError, match="mapping"):
|
||||
vault.write("memory/nisse-dev/B.md", "---\n- a\n---\nx\n", provenance={})
|
||||
|
||||
|
||||
def test_read_write_folders_are_made(tmp_path: Path) -> None:
|
||||
Vault(tmp_path / "v", {"memory/nisse-dev": "rw", "notes": "ro"})
|
||||
assert (tmp_path / "v" / "memory" / "nisse-dev").is_dir()
|
||||
assert not (tmp_path / "v" / "notes").exists() # not ours to make
|
||||
assert yaml # frontmatter is YAML
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Per-role transcript views (architecture L6; Development Plan M1.2)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.render import OpenAIChatRenderer
|
||||
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
|
||||
def new_store(tmp_path: Path) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
|
||||
|
||||
def texts(events: list[StoredEvent]) -> list[str]:
|
||||
return [getattr(e.event, "text", "") for e in events]
|
||||
|
||||
|
||||
# --- the projection, per role ----------------------------------------------------
|
||||
|
||||
|
||||
def delegated_milestone(store: StateStore) -> None:
|
||||
"""A milestone in which the driver failed and handed off to a helper."""
|
||||
store.transcript("a", "driver").append(PlanSubmitted(plan="1. fix it", model="y"))
|
||||
driver = store.transcript("a", "driver")
|
||||
driver.append(
|
||||
ToolCall(call_id="e1", tool="run_command", arguments={"command": "make"})
|
||||
)
|
||||
driver.append(ToolResult(call_id="e1", result="error: missing ;", success=False))
|
||||
helper = driver.as_actor("helper")
|
||||
helper.append(
|
||||
DelegationStarted(brief="make fails", role="helper", model="y", caps={})
|
||||
)
|
||||
helper.append(AssistantText(text="looking at main.c", model="y"))
|
||||
helper.append(
|
||||
ToolCall(call_id="h1", tool="read_file", arguments={"path": "main.c"})
|
||||
)
|
||||
helper.append(ToolResult(call_id="h1", result="int main()", success=True))
|
||||
helper.append(
|
||||
DelegationCompleted(
|
||||
handback="fixed the missing semicolon",
|
||||
artifacts=["main.c"],
|
||||
tokens=0,
|
||||
duration_s=1.0,
|
||||
exit_status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_the_driver_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
delegated_milestone(store)
|
||||
view = store.transcript("a", "driver").view()
|
||||
assert [type(e.event).__name__ for e in view] == [
|
||||
"ToolCall",
|
||||
"ToolResult",
|
||||
"UserMessage",
|
||||
]
|
||||
assert view[-1].event.text == (
|
||||
"[helper report, completed] fixed the missing semicolon"
|
||||
)
|
||||
|
||||
|
||||
def test_the_helper_sees_the_plan_and_the_drivers_work_as_narration(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = new_store(tmp_path)
|
||||
delegated_milestone(store)
|
||||
view = store.transcript("a", "helper").view()
|
||||
said = texts(view)
|
||||
assert said[0] == "[approved plan] 1. fix it"
|
||||
assert said[1] == "[driver] run_command(make) → failed: error: missing ;"
|
||||
# Its own events are unchanged, tool calls and all.
|
||||
assert any(isinstance(e.event, ToolCall) and e.event.call_id == "h1" for e in view)
|
||||
assert not any(
|
||||
isinstance(e.event, ToolCall) and e.event.call_id == "e1" for e in view
|
||||
)
|
||||
|
||||
|
||||
def test_a_view_keeps_log_ids_so_cursors_still_work(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
delegated_milestone(store)
|
||||
raw = store.transcript("a", "driver").read()
|
||||
view = store.transcript("a", "driver").view(after=raw[2].id)
|
||||
assert [e.id for e in view] == [raw[-1].id] # the helper's report
|
||||
|
||||
|
||||
# --- rendering ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_both_renderers_show_a_fact_as_a_labelled_user_message() -> None:
|
||||
events = [StoredEvent(id=1, ts="", event=Fact(text="PR #3 opened"))]
|
||||
openai = OpenAIChatRenderer().render(events, tools=None, model="m")
|
||||
assert openai["messages"] == [
|
||||
{"role": "user", "content": "[orchestrator] PR #3 opened"}
|
||||
]
|
||||
anthropic = AnthropicMessagesRenderer().render(events, tools=None, model="m")
|
||||
assert anthropic["messages"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "[orchestrator] PR #3 opened"}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_operator_messages_carry_their_channel(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
operator = store.transcript("a", "operator")
|
||||
for source in ("transport:@don", "gitea:issue:9:comment:4", "gitea:comment:5"):
|
||||
operator.append(UserMessage(text="hi", source=source))
|
||||
assert texts(store.transcript("a", "driver").view()) == [
|
||||
"[operator, Matrix] hi",
|
||||
"[operator, comment on plan issue #9] hi",
|
||||
"[operator, Gitea comment] hi",
|
||||
]
|
||||
|
||||
|
||||
def test_something_written_mid_call_moves_after_the_result(tmp_path: Path) -> None:
|
||||
# Live, M2.1: start_milestone's fact landed between the call and its
|
||||
# result, and the model API rejected every later request in the run.
|
||||
store = new_store(tmp_path)
|
||||
driver = store.transcript("a", "driver")
|
||||
driver.append(ToolCall(call_id="c1", tool="start_milestone", arguments={}))
|
||||
store.transcript("a", "orchestrator").append(Fact(text="milestone 1 started"))
|
||||
driver.append(ToolResult(call_id="c1", result="started", success=True))
|
||||
kinds = [type(e.event).__name__ for e in driver.view()]
|
||||
assert kinds == ["ToolCall", "ToolResult", "UserMessage"]
|
||||
body = AnthropicMessagesRenderer().render(driver.view(), tools=None, model="m")
|
||||
result_first = body["messages"][1]["content"][0]
|
||||
assert result_first["type"] == "tool_result"
|
||||
@@ -1,83 +0,0 @@
|
||||
import io
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.worker import InlineWorker, SerialWorker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker() -> SerialWorker:
|
||||
w = SerialWorker()
|
||||
w.start()
|
||||
yield w
|
||||
w.stop(timeout=5)
|
||||
|
||||
|
||||
def test_jobs_run_in_order_on_the_worker_thread(worker: SerialWorker) -> None:
|
||||
seen: list[tuple[int, str]] = []
|
||||
for n in range(3):
|
||||
worker.submit(lambda n=n: seen.append((n, threading.current_thread().name)))
|
||||
worker.call(lambda: None) # drains everything queued before it
|
||||
|
||||
assert [n for n, _ in seen] == [0, 1, 2]
|
||||
assert {name for _, name in seen} == {"nisse-worker"}
|
||||
|
||||
|
||||
def test_call_returns_the_result_and_raises_the_error(worker: SerialWorker) -> None:
|
||||
assert worker.call(lambda: 42) == 42
|
||||
|
||||
def boom() -> None:
|
||||
raise ValueError("bad job")
|
||||
|
||||
with pytest.raises(ValueError, match="bad job"):
|
||||
worker.call(boom)
|
||||
|
||||
|
||||
def test_a_failed_job_is_reported_and_the_worker_carries_on() -> None:
|
||||
errors: list[BaseException] = []
|
||||
worker = SerialWorker(on_error=errors.append)
|
||||
worker.start()
|
||||
|
||||
def boom() -> None:
|
||||
raise RuntimeError("lost job")
|
||||
|
||||
worker.submit(boom)
|
||||
assert worker.call(lambda: "still here") == "still here"
|
||||
assert [str(e) for e in errors] == ["lost job"]
|
||||
worker.stop(timeout=5)
|
||||
|
||||
|
||||
def test_call_from_the_worker_itself_runs_inline(worker: SerialWorker) -> None:
|
||||
# Queueing behind itself would deadlock.
|
||||
assert worker.call(lambda: worker.call(lambda: "nested")) == "nested"
|
||||
|
||||
|
||||
def test_the_store_is_usable_only_from_the_thread_that_owns_it(
|
||||
worker: SerialWorker, tmp_path: Path
|
||||
) -> None:
|
||||
store = worker.call(
|
||||
lambda: StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
|
||||
)
|
||||
assert worker.call(store.active_item) is None
|
||||
# The tripwire the worker design keeps on: any other thread fails loudly.
|
||||
with pytest.raises(Exception, match="same thread"):
|
||||
store.active_item()
|
||||
worker.call(store.close)
|
||||
|
||||
|
||||
def test_inline_worker_defers_a_job_submitted_from_inside_a_job() -> None:
|
||||
worker = InlineWorker()
|
||||
order: list[str] = []
|
||||
|
||||
def outer() -> None:
|
||||
order.append("outer start")
|
||||
worker.submit(lambda: order.append("inner"))
|
||||
order.append("outer end")
|
||||
|
||||
worker.submit(outer)
|
||||
assert order == ["outer start", "outer end", "inner"]
|
||||
assert not worker.busy
|
||||
Reference in new issue
Block a user