Merge pull request 'The call record: every model call, for the operator's study (M6.6a)' (#179) from call-record into main
This commit was merged in pull request #179.
This commit is contained in:
commit
c1f3d9dafb
22 files changed
+1346
-48
No files matched your search
@@ -1,11 +1,13 @@
|
||||
"""Running one task once with one driver model, and recording it."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -21,6 +23,7 @@ from nisse_core.log.canonical import (
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.loop.driver import Driver, RunOutcome
|
||||
from nisse_core.models import calls
|
||||
from nisse_core.models.catalog import ModelFacts
|
||||
from nisse_core.models.compact import Compactor
|
||||
from nisse_core.oplog import OpLog
|
||||
@@ -184,6 +187,37 @@ def _count(events: list[StoredEvent]) -> tuple[int, int]:
|
||||
return failed, invented
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _run_dir(keep: Path | None, workdir: Path | None, name: str) -> Iterator[Path]:
|
||||
"""Where a run's state store and call record go: kept under ``keep``
|
||||
for the status page (X6), or a temporary directory."""
|
||||
if keep is not None:
|
||||
root = keep / f"{name}-{uuid.uuid4().hex[:8]}"
|
||||
root.mkdir(parents=True)
|
||||
yield root
|
||||
return
|
||||
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
|
||||
yield Path(tmp)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _recording(client: Any, root: Path) -> Iterator[None]:
|
||||
"""The run's calls recorded beside its own state store (L7), as the
|
||||
agent records its own, so the run measures what the agent would do."""
|
||||
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
|
||||
had = hasattr(client, "call_recorder")
|
||||
previous = getattr(client, "call_recorder", None)
|
||||
client.call_recorder = recorder
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if had:
|
||||
client.call_recorder = previous
|
||||
else:
|
||||
del client.call_recorder
|
||||
recorder.close()
|
||||
|
||||
|
||||
def run_task(
|
||||
task: Task,
|
||||
facts: ModelFacts,
|
||||
@@ -194,6 +228,7 @@ def run_task(
|
||||
note: str = "",
|
||||
commit: str = "",
|
||||
workdir: Path | None = None,
|
||||
keep: Path | None = None,
|
||||
) -> RunRecord:
|
||||
"""Run one task once: seed a workspace, hand the driver an agreed item
|
||||
whose restatement is the task's plan, then judge the result with the
|
||||
@@ -202,8 +237,7 @@ def run_task(
|
||||
It is the loop's own driver, system prompt and tools, so what is measured
|
||||
is what the agent would do, without the harness around it: the milestone
|
||||
is started but no branch is cut, and finishing it ends the run."""
|
||||
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
|
||||
root = Path(tmp)
|
||||
with _run_dir(keep, workdir, task.name) as root, _recording(client, root):
|
||||
# The workspace is on the build VM, like the agent's own (L13).
|
||||
workspace = Workspace(
|
||||
host,
|
||||
|
||||
@@ -63,6 +63,8 @@ OPTIONAL_KEYS = (
|
||||
"roles.classifier",
|
||||
"llm.max_output_tokens",
|
||||
"llm.request_timeout_s",
|
||||
# Record every model call for the operator's study (L7); on unless false.
|
||||
"call_record",
|
||||
)
|
||||
# Settings the old loop had (until M2.8), and what to do instead.
|
||||
RETIRED_KEYS = {
|
||||
@@ -444,6 +446,7 @@ class AgentConfig:
|
||||
profiles: Mapping[str, Profile] = field(default_factory=dict)
|
||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
||||
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
||||
call_record: bool = True # every model call recorded for the operator (L7)
|
||||
tuning: Tuning = field(default_factory=Tuning)
|
||||
vault: VaultConfig | None = None
|
||||
config_file: Path | None = None
|
||||
@@ -521,6 +524,9 @@ class AgentConfig:
|
||||
errors.append("unset or empty — " + ", ".join(missing))
|
||||
|
||||
max_output, request_timeout = llm_numbers(values, errors)
|
||||
record = values.get("call_record", True)
|
||||
if not isinstance(record, bool):
|
||||
errors.append("call_record: true or false")
|
||||
if errors:
|
||||
raise ConfigError("cannot start: " + "; ".join(errors))
|
||||
|
||||
@@ -548,6 +554,7 @@ class AgentConfig:
|
||||
profiles=profiles,
|
||||
max_output_tokens=max_output,
|
||||
request_timeout_s=request_timeout,
|
||||
call_record=record,
|
||||
tuning=tuning,
|
||||
vault=vault,
|
||||
config_file=config_file,
|
||||
|
||||
@@ -38,6 +38,7 @@ from nisse_core.log.canonical import (
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_core.models import calls
|
||||
from nisse_core.models.llm import LLMClient, LLMError, UpstreamUnavailable
|
||||
from nisse_core.state.cancel import Cancel, skip_calls
|
||||
from nisse_core.state.reconcile import Reconciler
|
||||
@@ -333,6 +334,21 @@ class Delegation:
|
||||
|
||||
def _loop(
|
||||
self, transcript: Transcript, brief: str, branch: str, started_at: float
|
||||
) -> DelegationResult:
|
||||
"""The delegation's turns, recorded as a run of its own, inside the
|
||||
driver's (L7)."""
|
||||
with calls.run(
|
||||
calls.recorder_of(self._client),
|
||||
work_item=transcript.work_item,
|
||||
role=transcript.actor,
|
||||
kind="delegation",
|
||||
) as recorded:
|
||||
result = self._turns(transcript, brief, branch, started_at)
|
||||
recorded.end(result.status, result.note)
|
||||
return result
|
||||
|
||||
def _turns(
|
||||
self, transcript: Transcript, brief: str, branch: str, started_at: float
|
||||
) -> DelegationResult:
|
||||
system = (
|
||||
f"{SYSTEM_PROMPT.format(reader=self._reader)}\n\n"
|
||||
|
||||
@@ -14,6 +14,7 @@ from nisse_core.gitea import GiteaClient
|
||||
from nisse_core.knowledge.memory import Memory
|
||||
from nisse_core.knowledge.vault import Vault
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.models import calls
|
||||
from nisse_core.models.catalog import ModelCatalog, select_client
|
||||
from nisse_core.models.compact import Compactor
|
||||
from nisse_core.models.context import BudgetedClient
|
||||
@@ -126,6 +127,9 @@ def make_llm_clients(
|
||||
assert facts.max_input_tokens is not None # require_limits checked
|
||||
return facts.max_input_tokens, facts.max_output_tokens
|
||||
|
||||
# Both routes record into one file (L7).
|
||||
recorder = calls.recorder_for(config.state_db, config.call_record, oplog)
|
||||
|
||||
def budgeted(client: object) -> BudgetedClient:
|
||||
# Every request, on either route, is fitted to its model's window.
|
||||
return BudgetedClient(
|
||||
@@ -138,6 +142,7 @@ def make_llm_clients(
|
||||
catalog.facts(m).supports_tool_choice is True
|
||||
),
|
||||
oplog=oplog, # a cut that fitting has to make is logged
|
||||
call_recorder=recorder,
|
||||
)
|
||||
|
||||
normalized = budgeted(
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
|
||||
from nisse_core.authority.actions import CONTAINED, DANGEROUS, Action, Verdict
|
||||
from nisse_core.log.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_core.models.calls import purpose
|
||||
from nisse_core.models.llm import LLMClient, LLMError
|
||||
|
||||
SAFE = "safe"
|
||||
@@ -120,19 +121,20 @@ class ActionClassifier:
|
||||
shown = f"{model} ({resolved})" if resolved and resolved != model else model
|
||||
prompt = self.input_for(action, scope, workspace)
|
||||
try:
|
||||
produced = self._client.complete(
|
||||
[
|
||||
StoredEvent(
|
||||
id=0,
|
||||
ts="",
|
||||
event=UserMessage(text=prompt, source="action classifier"),
|
||||
actor="orchestrator",
|
||||
)
|
||||
],
|
||||
tools=[VERDICT],
|
||||
model=model,
|
||||
system=SYSTEM,
|
||||
)
|
||||
with purpose("classification", role="classifier"):
|
||||
produced = self._client.complete(
|
||||
[
|
||||
StoredEvent(
|
||||
id=0,
|
||||
ts="",
|
||||
event=UserMessage(text=prompt, source="action classifier"),
|
||||
actor="orchestrator",
|
||||
)
|
||||
],
|
||||
tools=[VERDICT],
|
||||
model=model,
|
||||
system=SYSTEM,
|
||||
)
|
||||
except LLMError as exc:
|
||||
return _closed(f"the classifier's call failed: {exc}"), shown
|
||||
except Exception as exc: # noqa: BLE001 -- fails closed, whatever it was
|
||||
|
||||
@@ -11,6 +11,7 @@ The driver's model reads for now; the classifier role takes over in M2.6.
|
||||
from typing import Any
|
||||
|
||||
from nisse_core.log.canonical import StoredEvent, ToolCall, UserMessage
|
||||
from nisse_core.models.calls import purpose
|
||||
from nisse_core.models.llm import LLMClient, LLMError
|
||||
|
||||
|
||||
@@ -65,19 +66,20 @@ class GoAheadReader:
|
||||
return False
|
||||
prompt = f"The proposal:\n\n{proposal}\n\nThe operator's reply:\n\n{reply}"
|
||||
try:
|
||||
produced = self._client.complete(
|
||||
[
|
||||
StoredEvent(
|
||||
id=0,
|
||||
ts="",
|
||||
event=UserMessage(text=prompt, source="go-ahead reader"),
|
||||
actor="orchestrator",
|
||||
)
|
||||
],
|
||||
tools=[VERDICT],
|
||||
model=model or self._model,
|
||||
system=SYSTEM,
|
||||
)
|
||||
with purpose("go-ahead reading", role="classifier"):
|
||||
produced = self._client.complete(
|
||||
[
|
||||
StoredEvent(
|
||||
id=0,
|
||||
ts="",
|
||||
event=UserMessage(text=prompt, source="go-ahead reader"),
|
||||
actor="orchestrator",
|
||||
)
|
||||
],
|
||||
tools=[VERDICT],
|
||||
model=model or self._model,
|
||||
system=SYSTEM,
|
||||
)
|
||||
except LLMError:
|
||||
return False # unread: the driver reads it as an answer instead
|
||||
return any(_yes(event) for event in produced)
|
||||
|
||||
Reference in new issue
Block a user