diff --git a/predicators/agent_sdk/belief_probe.py b/predicators/agent_sdk/belief_probe.py index 96e0f7509..0ddab1a7a 100644 --- a/predicators/agent_sdk/belief_probe.py +++ b/predicators/agent_sdk/belief_probe.py @@ -177,9 +177,13 @@ class _StrLikeResult: """String conveniences shared by the probe result types. The results print like strings, so agents naturally slice - (``res[-800:]``) and search (``'Goal reached: True' in res``) them; - without these dunders both moves are ``TypeError``s that cost a - recovery turn (and recur after compaction erases the lesson). + (``res[-800:]``), search (``'Goal reached: True' in res``), and call + string methods (``res.split('\\n')`` destroyed a 1200 s refine + result in run_20260830_145216 - AttributeError, then a full + identical re-run); without these both moves are errors that cost a + recovery turn (and recur after compaction erases the lesson). Any + ``str`` method not shadowed by a real attribute delegates to the + rendered report, so the whole string API works. """ @property @@ -196,6 +200,16 @@ def __contains__(self, item: str) -> bool: def __len__(self) -> int: return len(repr(self)) + def __getattr__(self, name: str) -> Any: + # Only reached for attributes not found normally (dataclass + # fields and real methods take precedence). Delegate public str + # methods (.split, .splitlines, .find, .lower, ...) to the + # rendered report; anything else raises AttributeError as usual. + if not name.startswith("_") and hasattr(str, name): + return getattr(repr(self), name) + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {name!r}") + @dataclasses.dataclass(repr=False) class ProbeResult(_StrLikeResult): @@ -271,7 +285,8 @@ def __repr__(self) -> str: "trial - the rate estimates real execution reliability" if self.fresh_env_per_trial else "shared session env - trials are correlated, treat the " - "rate as optimistic") + "rate as optimistic; this session has no fresh-env " + "scope, so fresh=True is likewise unavailable") scored = [t for t in self.trials if t.get("solved") is not None] headline = f"Trials: {self.successes}/{n} reached the goal" if scored: @@ -834,8 +849,11 @@ def state( ) -> Union[Dict[str, Dict[str, float]], Dict[str, float]]: """Full-precision feature dict of the current state. - ``state()`` -> ``{obj: {feat: value}}`` for all objects; - ``state("domino_1")`` -> that object's ``{feat: value}``. + ``state()`` -> ``{"name:type": {feat: value}}`` for all objects + - keys use the same ``name:type`` form as atoms, plan lines, and + the prompt, so a key read anywhere else indexes here directly; + ``state("domino_1")`` and ``state("domino_1:domino")`` both -> + that object's ``{feat: value}``. """ cur = self._require_state() @@ -847,13 +865,17 @@ def _features(obj: Any) -> Dict[str, float]: if obj_name is not None: # Sweep loops call the single-object form per iteration; - # keep it O(one object), not O(scene). + # keep it O(one object), not O(scene). Accept both the bare + # name and the name:type form every other surface prints + # (bare-only lookups cost a KeyError per session: + # run_20260830 hit it in four sessions). + bare = obj_name.split(":", 1)[0] for obj in cur: - if obj.name == obj_name: + if obj.name == bare: return _features(obj) raise ValueError(f"Unknown object '{obj_name}'. Available: " - f"{sorted(o.name for o in cur)}") - return {obj.name: _features(obj) for obj in sorted(cur, key=str)} + f"{sorted(str(o) for o in cur)}") + return {str(obj): _features(obj) for obj in sorted(cur, key=str)} def atoms(self) -> List[str]: """Sorted ground atoms true in the current state.""" @@ -1074,7 +1096,12 @@ def run( ``submit_plan``'s validation rollouts report theirs the same way. A single run (``trials=1``) executes entirely at ``S``; a physics sweep runs every point at ``S`` instead of the - base. + base. Without ``seed=``, and from the task's unmodified initial + state (plain ``reset()``, no rollout since), ``trials=N`` runs + the IDENTICAL rollout set as ``submit_plan``'s N-rollout capture + gate (fresh env per rollout, planner seeds ``base..base+N-1``), + so a trials score here is exactly the gate's verdict on this + plan. SUBSTRATE: a default single run executes on the WARM shared session env from the probe's current state - a feature for @@ -1135,13 +1162,16 @@ def run( ctx.test_call_id += 1 probe_task, sketch_steps, all_predicates, notices = \ self._parse_sketch(plan_text) + # Ground via the shared helper so an annotated Wait waits for + # its annotated atoms here exactly as in refine, submit_plan, + # and real execution (see submit_plan's grounding comment). grounded: List[Any] = [] for st in sketch_steps: params = (st.initial_params if st.initial_params is not None else np.array([], dtype=np.float32)) grounded.append( - st.option.ground(list(st.objects), - np.asarray(params, dtype=np.float32))) + bilevel_sketch.ground_step( + st, np.asarray(params, dtype=np.float32))) report_preds = ctx.predicates @@ -1177,8 +1207,12 @@ def _horizon_note(total_actions: int) -> Optional[str]: "physics_sweep=True, but no identified physical " "parameters with nonzero posterior width are deployed " "this cycle, so there is no uncertainty range to " - "sweep. Use trials= to measure execution reliability " - "at the current physics instead.") + "sweep. (submit_plan's rule-parameter ensemble margin " + "is a different, automatic gate over the learned rule " + "constants - it still runs at submission and is not " + "reachable through physics_sweep.) Use trials= to " + "measure execution reliability at the current physics " + "instead.") point_dicts: List[Dict[str, Any]] = [] all_points: List[Optional[Dict[str, float]]] = \ [None] + sweep_points @@ -1462,6 +1496,14 @@ def _on_step(i: int, outcome: Any) -> None: img = render_scene_image( ctx, f"probe_step_{i}_{outcome.option.name}") if render else None + if (outcome.option.name == "Wait" and failure is None and + outcome.num_actions >= CFG.max_num_steps_option_rollout): + notices.append( + f"step {i} (Wait) ran to the option-rollout cap " + f"({outcome.num_actions} actions): its wait-target " + "atoms never became true in the belief (and no other " + "atom changed). Check whether the awaited change is " + "modeled, or drop the Wait.") step_dicts.append({ "option": sig, @@ -2011,9 +2053,20 @@ def gated_solved_check(states: List[State], labels: List[Any], "full rollout of these params as a solve.") elif require_goal: verdict = ("goal-reached - the task's goal atoms held at the " - "final step; no evaluator verdict (use " - "require_solved=True from a pristine reset() for " - "that).") + "final step") + # Recommend the evaluator gate only when the task HAS an + # evaluator: sessions without one wasted a call per session + # following this advice into "this task defines no task + # evaluator" (run_20260830, three sessions). + if (self._base_task is not None + and self._base_task.evaluator is not None): + verdict += ("; no evaluator verdict (use " + "require_solved=True from a pristine reset() " + "for that).") + else: + verdict += (" (this task defines no evaluator, so " + "goal-reached is the strongest verdict " + "available).") else: verdict = ("executed - every step established its subgoal " "annotation; the task goal was NOT checked (set " diff --git a/predicators/agent_sdk/bilevel_sketch.py b/predicators/agent_sdk/bilevel_sketch.py index 6feed94c5..ff66682b1 100644 --- a/predicators/agent_sdk/bilevel_sketch.py +++ b/predicators/agent_sdk/bilevel_sketch.py @@ -8,8 +8,9 @@ - ``sketch_types``: shared dataclasses (``GroundSampler``, ``SketchStep``) that parsing constructs and refinement/execution consume. -- ``sketch_prompts``: ``build_solve_prompt``, the solve/explore prompt - builder. +- ``sketch_prompts``: ``build_solve_system_prompt`` and + ``build_solve_prompt``, the solve/explore system-prompt and query + builders (rendered from ``prompts/*.md``). - ``sketch_parsing``: the sketch-line grammar - step/plan formatters and the parsers for subgoal / ``~`` ground-sampler annotations and continuous params. @@ -26,7 +27,8 @@ parse_region_annotations, parse_sketch_from_text, \ parse_subgoal_annotations, strip_code_fences, strip_region_annotations, \ strip_subgoal_annotations -from predicators.agent_sdk.sketch_prompts import build_solve_prompt +from predicators.agent_sdk.sketch_prompts import build_early_stop_note, \ + build_solve_prompt, build_solve_system_prompt from predicators.agent_sdk.sketch_refinement import DeepestFailure, \ InfoScorer, RefineOutcome, StepProbeSuggestion, ground_step, \ refine_and_validate_report, refine_sketch, resolve_refine_timeout, \ @@ -42,7 +44,9 @@ "SketchStep", "StepOutcome", "StepProbeSuggestion", + "build_early_stop_note", "build_solve_prompt", + "build_solve_system_prompt", "execute_plan_forward", "format_plan_lines", "format_sketch_lines", diff --git a/predicators/agent_sdk/docker_sandbox.py b/predicators/agent_sdk/docker_sandbox.py index 16607556a..69ec6dd8c 100644 --- a/predicators/agent_sdk/docker_sandbox.py +++ b/predicators/agent_sdk/docker_sandbox.py @@ -73,8 +73,8 @@ _STDERR_TAIL_LINES = 20 # Build Docker-specific prompts from shared templates. -# CLAUDE.md is built per-instance with the phase tag so the agent reads -# phase-appropriate strategy guidance every turn (see build_claude_md). +# CLAUDE.md (sandbox mechanics only; see build_claude_md) is written +# into the sandbox when it is populated. _SANDBOX_SYSTEM_PROMPT = build_sandbox_system_prompt( env_description="an isolated Docker sandbox", workspace_description="/sandbox/", diff --git a/predicators/agent_sdk/journal.py b/predicators/agent_sdk/journal.py index b5bc3293f..6d45998f3 100644 --- a/predicators/agent_sdk/journal.py +++ b/predicators/agent_sdk/journal.py @@ -96,7 +96,12 @@ def read_strategy(sandbox_dir: Optional[str], with open(path, "r", encoding="utf-8") as f: content = f.read().strip() if len(content) > max_chars: - content = (content[:max_chars].rstrip() + + # Cut at a line boundary, never mid-word. + head = content[:max_chars] + cut = head.rfind("\n") + if cut > 0: + head = head[:cut] + content = (head.rstrip() + "\n[strategy truncated at the prompt cap - read " f"./{STRATEGY_FILENAME} for the rest]") return content diff --git a/predicators/agent_sdk/learn_prompts.py b/predicators/agent_sdk/learn_prompts.py new file mode 100644 index 000000000..9173a12cc --- /dev/null +++ b/predicators/agent_sdk/learn_prompts.py @@ -0,0 +1,231 @@ +"""Prompt construction for the learning (simulator synthesis) phase. + +Rendered from ``learn_system.md``, ``learn_message.md``, +``learn_predicate_invention.md``, and ``learn_partial_observability.md`` +in ``predicators/agent_sdk/prompts`` (see :mod:`prompt_templates`). The +approach classes gather the per-instance values (digests, data roster, +reports, paths) and call these pure builders, so every prompt can be +rendered and reviewed without a live session. +""" +import re +from typing import Any, Mapping, Sequence + +from predicators.agent_sdk.prompt_templates import render + +_BLANK_RUN_RE = re.compile(r"\n{3,}") + + +def _join(parts: Sequence[str]) -> str: + text = "\n\n".join(p.strip("\n") for p in parts if p and p.strip()) + return _BLANK_RUN_RE.sub("\n\n", text).strip("\n") + "\n" + + +# --------------------------------------------------------------------------- +# System prompt +# --------------------------------------------------------------------------- + + +def build_learn_system_prompt( + *, + partially_observable: bool, + residual_rule_signature: str, + scene_viz_hint: str, + physical_params_section: str = "", + extra_sections: Sequence[str] = (), + latent_extra_sections: Sequence[str] = (), + workflow_extra: str = "", +) -> str: + """Compose the synthesis system prompt. + + ``partially_observable`` selects the recurrent 5-argument rule + signature and appends the recurrent-rules tutorial; + ``residual_rule_signature`` is the matching ``def`` line for the + geometric-gate example. ``physical_params_section`` is the rendered + system-identification section (empty when the env reveals no + parameters). ``extra_sections`` (subclass additions such as + predicate invention) are inserted after the validation guidance; + ``latent_extra_sections`` follow the recurrent-rules tutorial (only + rendered when ``partially_observable``); ``workflow_extra`` is + appended to the workflow's validation step. + """ + signature = render( + "learn_system", + "rule_signature_po" if partially_observable else "rule_signature_fo") + parts = [ + render("learn_system", "intro"), + render("learn_system", "produce"), + physical_params_section, + signature, + render("learn_system", "cmds"), + render("learn_system", "multi_object"), + render("learn_system", "timing"), + render("learn_system", + "geometric_gates", + residual_rule_signature=residual_rule_signature, + scene_viz_hint=scene_viz_hint), + render("learn_system", "paramspec"), + render("learn_system", "preinjected"), + render("learn_system", "tools"), + render("learn_system", "validation"), + *extra_sections, + ] + if partially_observable: + parts.append(render("learn_partial_observability", "rules")) + parts.extend(latent_extra_sections) + parts += [ + render("learn_system", "plan_format"), + render("learn_system", "deliverables"), + render("learn_system", + "workflow", + workflow_extra=(" " + + workflow_extra) if workflow_extra else ""), + ] + return _join(parts) + + +def render_physical_params_section( + info: Mapping[str, Mapping[str, Any]]) -> str: + """The ``PHYSICAL_PARAMS`` section for a revealed parameter menu. + + ``info`` maps a parameter name to its ``default``, ``lo``, ``hi``, + ``description``, and optional ``scale``; empty input renders + nothing, so envs without a menu never see the feature mentioned. + """ + if not info: + return "" + lines = [] + for name, meta in info.items(): + scale_note = (", fitted in log-space" + if meta.get("scale") == "log" else "") + lines.append(f"- `{name}` (built-in {meta['default']:.4g}, fit " + f"box [{meta['lo']:.4g}, {meta['hi']:.4g}]" + f"{scale_note}): {meta['description']}") + return render("learn_system", + "physical_params", + param_list="\n".join(lines)) + + +def render_predicate_invention_section(scene_workbench: str) -> str: + """The predicate-invention system-prompt section.""" + return render("learn_predicate_invention", + "system", + scene_workbench=scene_workbench) + + +def render_predicate_latent_section() -> str: + """The predicate-side latent guidance (invention arms, PO only).""" + return render("learn_partial_observability", "predicates") + + +def render_predicate_workflow_extra() -> str: + """The invention arm's addition to the workflow's validation step.""" + return render("learn_predicate_invention", "workflow_extra") + + +# --------------------------------------------------------------------------- +# First message +# --------------------------------------------------------------------------- + + +def build_learn_message( + *, + n_trajs: int, + n_transitions: int, + n_demos: int, + n_interaction: int, + trajectory_listing: str, + structs_ref: str, + inferred_hint: str, + predicate_listing: str, + types_digest: str, + options_digest: str, + simulator_file: str, + objective_block: str = "", + prior_state_block: str = "", + divergence_block: str = "", + base_sim_block: str = "", + tools_block: str = "", + extra_messages: Sequence[str] = (), +) -> str: + """Compose the synthesis session's first message. + + Every block argument is already rendered (see the ``render_*`` + helpers below) or empty. ``extra_messages`` (predicate invention, + partial observability, sampler synthesis) are appended in order. + """ + body = render( + "learn_message", + "skeleton", + n_trajs=str(n_trajs), + n_transitions=str(n_transitions), + n_demos=str(n_demos), + n_interaction=str(n_interaction), + trajectory_listing=trajectory_listing.strip("\n"), + objective_block=objective_block, + prior_state_block=prior_state_block, + divergence_block=divergence_block, + structs_ref=structs_ref, + base_sim_block=base_sim_block, + inferred_hint=inferred_hint, + predicate_listing=predicate_listing, + types_digest=types_digest.strip("\n"), + options_digest=options_digest.strip("\n"), + tools_block=tools_block, + simulator_file=simulator_file, + ) + return _join([body, *extra_messages]) + + +def render_divergence_block(report: str, has_prior_model: bool) -> str: + """The start-of-session residual report section.""" + return render("learn_message", + "divergence_prior" if has_prior_model else "divergence_base", + report=report.strip("\n")) + + +def render_base_sim_block(refs: Sequence[str]) -> str: + """The base-simulator source listing, or empty.""" + if not refs: + return "" + return render("learn_message", + "base_sim", + ref_listing="\n".join(f" - {r}" for r in refs)) + + +def render_tools_block(tool_names: Sequence[str]) -> str: + """The session's tool roster, or empty.""" + if not tool_names: + return "" + return render("learn_message", + "tools", + tool_listing="\n".join(f" - {t}" for t in tool_names)) + + +def render_objective_block(description: str) -> str: + """The env's public task objective section, or empty.""" + if not description: + return "" + return render("learn_message", "objective", description=description) + + +def render_prior_state_block(prior_files: Sequence[str]) -> str: + """The prior-cycle-state paragraph for the artifacts found, or empty.""" + if not prior_files: + return "" + return render("learn_message", + "prior_state", + prior_files=" and ".join(prior_files)) + + +def render_predicate_invention_message(predicates_file: str, + goal_block: str) -> str: + """The invention arm's addition to the first message.""" + return render("learn_predicate_invention", + "message", + predicates_file=predicates_file, + goal_block=goal_block.strip("\n")) + + +def render_partial_observability_message() -> str: + """The short partial-observability note for the first message.""" + return render("learn_partial_observability", "message") diff --git a/predicators/agent_sdk/local_sandbox.py b/predicators/agent_sdk/local_sandbox.py index 3b87e9397..37b15453c 100644 --- a/predicators/agent_sdk/local_sandbox.py +++ b/predicators/agent_sdk/local_sandbox.py @@ -55,8 +55,8 @@ _DEADLINE_INTERRUPT_SLACK_S = 180 # Build local-sandbox-specific prompts from shared templates. -# CLAUDE.md is built per-instance with the phase tag so the agent reads -# phase-appropriate strategy guidance every turn (see build_claude_md). +# CLAUDE.md (sandbox mechanics only; see build_claude_md) is written +# into the sandbox when it is populated. _LOCAL_SANDBOX_SYSTEM_PROMPT = build_sandbox_system_prompt( env_description="a local sandbox environment", workspace_description="the current directory", diff --git a/predicators/agent_sdk/log_formatter.py b/predicators/agent_sdk/log_formatter.py index 548e2d307..304da0ad5 100644 --- a/predicators/agent_sdk/log_formatter.py +++ b/predicators/agent_sdk/log_formatter.py @@ -52,19 +52,48 @@ def format_conversation_markdown( lines.append("## Conversation\n") turn_num = 0 + # Duplicate suppression: the SDK can deliver the same assistant + # message (and its tool results) more than once, and tool_use / + # tool_result ids are unique per call, so a repeated id is always a + # re-render of an already-logged turn (run_20260830 transcripts + # rendered turns twice verbatim). + seen_tool_use_ids: set = set() + seen_tool_result_ids: set = set() for entry in collected: etype = entry.get("type", "") if etype == "assistant": + blocks = entry.get("content", []) + ids = [ + b.get("id") for b in blocks if isinstance(b, dict) + and b.get("type") == "tool_use" and b.get("id") + ] + if ids and all(i in seen_tool_use_ids for i in ids): + continue + # Render into a buffer first: a turn whose blocks produce no + # output (empty or redacted thinking) gets no header at all, + # instead of an empty "### Turn N" stub. + body: List[str] = [] + for block in blocks: + _format_assistant_block(block, body) + if not body: + continue + seen_tool_use_ids.update(ids) turn_num += 1 if turn_num > 1: lines.append("---\n") lines.append(f"### Turn {turn_num}\n") - for block in entry.get("content", []): - _format_assistant_block(block, lines) + lines.extend(body) elif etype == "user": for block in entry.get("content", []): + if (isinstance(block, dict) + and block.get("type") == "tool_result"): + tid = block.get("tool_use_id") + if tid and tid in seen_tool_result_ids: + continue + if tid: + seen_tool_result_ids.add(tid) _format_user_block(block, lines) elif etype == "result": diff --git a/predicators/agent_sdk/prompt_templates.py b/predicators/agent_sdk/prompt_templates.py new file mode 100644 index 000000000..ffa4e8bde --- /dev/null +++ b/predicators/agent_sdk/prompt_templates.py @@ -0,0 +1,137 @@ +"""Loader for the agent prompt templates in ``predicators/agent_sdk/prompts``. + +Every prompt the agent receives (per-phase system prompts, the solve +and explore query, the synthesis learn message, the sandbox CLAUDE.md) +is authored as Markdown in that directory and composed here, so the +text can be read, diffed, and reproduced as a document rather than +reassembled from string concatenation. + +Template format: + +- A file is split into named sections by marker lines of the form + ````. Text before the first marker is ignored + (it is the file's own header comment). +- ``__UPPER_SNAKE__`` placeholders are substituted at render time. + Rendering fails loudly when a template placeholder has no value, so + a renamed placeholder cannot silently ship as literal text. +- Prose is authored hard-wrapped; :func:`render` joins wrapped prose + lines into one line per paragraph while preserving headings, lists, + tables, code fences, and indented lines. +""" +import functools +import os +import re +from typing import Dict, List + +_PROMPTS_DIR = os.path.join(os.path.dirname(__file__), "prompts") +_SECTION_RE = re.compile(r"^[ \t]*$", + re.MULTILINE) +_PLACEHOLDER_RE = re.compile(r"__([A-Z][A-Z0-9_]*)__") +# Matches "1. ", "12) " etc. at the start of a stripped line. +_NUMBERED_ITEM_RE = re.compile(r"^\d+[.)]\s") + + +@functools.lru_cache(maxsize=None) +def load_sections(template: str) -> Dict[str, str]: + """Return ``{section_name: raw_text}`` for ``prompts/