Skip to content

Commit 1d91c52

Browse files
committed
Fork per-class exploration anchor for dynamic access
The dynamic-access iterative strategy previously ran every attempt for a class on one shared session, so each attempt re-sent the full static context and dragged the prior attempts' turns forward. Now each class is explored once in a read-only anchor turn (new dynamic-access-explore prompt) that plans coverage without writing files. Every attempt forks that anchor and sends only a slim generation prompt (dynamic-access-iteration, rewritten) carrying the latest report delta. Because a fork branches the conversation but not the working tree, the slim prompt tells the fork to read the on-disk tests and extend them additively so it does not clobber coverage authored by a sibling attempt it has no memory of. A discarded fork child would otherwise drop its tokens from run metrics. The fork child is now a context manager: on block exit it folds its token delta (current total minus the baseline captured at fork, before its first turn) into the anchor's fork-usage accumulator, and the token properties report own plus forked usage. Call sites just wrap the fork in `with`, so no per-site accounting is needed.
1 parent 6508370 commit 1d91c52

9 files changed

Lines changed: 407 additions & 65 deletions

File tree

forge/ai_workflows/agents/agent.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ class MyAgent(Agent):
3232

3333
_registry: dict[str, type["Agent"]] = {}
3434

35+
# Fork token accounting. A fork child inherits this agent's counters, spends
36+
# its own tokens, and is then discarded. On the child's ``with``-block exit
37+
# it folds its delta into the parent's accumulators below, so a discarded
38+
# child's usage still surfaces in run metrics. Token-tracking agents add
39+
# these accumulators into their token properties. Class-level defaults let
40+
# this work without every backend initializing them in ``__init__``.
41+
_forked_tokens_sent: int = 0
42+
_forked_tokens_received: int = 0
43+
_forked_tokens_cached: int = 0
44+
_fork_parent: "Agent | None" = None
45+
_fork_baseline: "tuple[int, int, int] | None" = None
46+
3547
@classmethod
3648
def register(cls, agent_key: str):
3749
"""Class decorator that registers an agent implementation under the given key."""
@@ -68,6 +80,58 @@ def cached_input_tokens_used(self) -> int | None:
6880
"""Return cumulative cached input tokens when the backend reports them."""
6981
return None
7082

83+
def add_forked_usage(
84+
self,
85+
tokens_sent: int,
86+
tokens_received: int,
87+
cached_input_tokens: int = 0,
88+
) -> None:
89+
"""Accumulate token usage handed back by a discarded fork child.
90+
91+
Token-tracking agents surface these accumulators through their token
92+
properties; keeping them on the base means the bookkeeping works for any
93+
backend without extra per-agent code.
94+
"""
95+
self._forked_tokens_sent += max(int(tokens_sent), 0)
96+
self._forked_tokens_received += max(int(tokens_received), 0)
97+
self._forked_tokens_cached += max(int(cached_input_tokens), 0)
98+
99+
def _begin_fork_child(self, parent: "Agent") -> None:
100+
"""Stamp a freshly forked child with its parent and pre-turn baseline.
101+
102+
Call this after the child inherits the parent's counters but before its
103+
first turn is counted, so the child's delta (``total - baseline``) covers
104+
every turn it runs, including the initial generation prompt.
105+
"""
106+
self._fork_parent = parent
107+
self._fork_baseline = self._usage_tuple()
108+
109+
def _usage_tuple(self) -> tuple[int, int, int]:
110+
"""Current (sent, received, cached) totals, tolerant of ``None`` cached."""
111+
return (
112+
int(self.total_tokens_sent or 0),
113+
int(self.total_tokens_received or 0),
114+
int(self.cached_input_tokens_used or 0),
115+
)
116+
117+
def __enter__(self) -> "Agent":
118+
return self
119+
120+
def __exit__(self, exc_type, exc, traceback) -> bool:
121+
# Block exit is the fork child's "kill" point: fold its delta into the
122+
# parent so the discarded child's tokens survive in run metrics.
123+
if self._fork_parent is not None:
124+
sent, received, cached = self._usage_tuple()
125+
base_sent, base_received, base_cached = self._fork_baseline or (0, 0, 0)
126+
self._fork_parent.add_forked_usage(
127+
sent - base_sent,
128+
received - base_received,
129+
cached - base_cached,
130+
)
131+
self._fork_parent = None
132+
self._fork_baseline = None
133+
return False
134+
71135
def _create_session_log_path(
72136
self,
73137
agent_name: str,

forge/ai_workflows/agents/codex_agent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,15 @@ def __init__(
128128

129129
@property
130130
def total_tokens_sent(self) -> int:
131-
return self._total_tokens_sent
131+
return self._total_tokens_sent + self._forked_tokens_sent
132132

133133
@property
134134
def total_tokens_received(self) -> int:
135-
return self._total_tokens_received
135+
return self._total_tokens_received + self._forked_tokens_received
136136

137137
@property
138138
def cached_input_tokens_used(self) -> int:
139-
return self._cached_input_tokens_used
139+
return self._cached_input_tokens_used + self._forked_tokens_cached
140140

141141
@property
142142
def thread_id(self) -> str | None:
@@ -302,6 +302,7 @@ def fork(self, prompt: str) -> "CodexAgent":
302302
child._total_tokens_sent = self._total_tokens_sent
303303
child._cached_input_tokens_used = self._cached_input_tokens_used
304304
child._total_tokens_received = self._total_tokens_received
305+
child._begin_fork_child(self)
305306
child.send_prompt(prompt)
306307
return child
307308

@@ -315,6 +316,7 @@ def compact_fork(self, prompt: str) -> "CodexAgent":
315316
child._total_tokens_sent = self._total_tokens_sent
316317
child._cached_input_tokens_used = self._cached_input_tokens_used
317318
child._total_tokens_received = self._total_tokens_received
319+
child._begin_fork_child(self)
318320
child.send_prompt(prompt)
319321
return child
320322

forge/ai_workflows/agents/pi_agent.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ def __init__(
6060

6161
@property
6262
def total_tokens_sent(self) -> int:
63-
return self._total_tokens_sent
63+
return self._total_tokens_sent + self._forked_tokens_sent
6464

6565
@property
6666
def total_tokens_received(self) -> int:
67-
return self._total_tokens_received
67+
return self._total_tokens_received + self._forked_tokens_received
6868

6969
@property
7070
def cached_input_tokens_used(self) -> int:
71-
return self._cached_input_tokens_used
71+
return self._cached_input_tokens_used + self._forked_tokens_cached
7272

7373
def graphify(self, source_dirs: list[str]) -> str:
7474
"""Send /skill:graphify to the Pi session to build a merged knowledge graph context."""
@@ -134,6 +134,7 @@ def fork(self, prompt: str) -> "PiAgent":
134134
child._prev_input = self._prev_input
135135
child._prev_output = self._prev_output
136136
child._prev_cache = self._prev_cache
137+
child._begin_fork_child(self)
137138
child._apply_fork_token_delta(result.session_stats)
138139
child._print_session_log_once("Pi", child._session_log_path)
139140
child._write_turn_log(child._session_path, prompt, result)

forge/ai_workflows/core/dynamic_access_iterative_strategy.py

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DynamicAccessCoverageReport,
1818
compute_class_delta,
1919
format_call_sites,
20+
format_full_report,
2021
load_dynamic_access_coverage_report,
2122
)
2223
from utility_scripts.dynamic_access_exhaust_report import DynamicAccessExhaustReport
@@ -34,6 +35,7 @@
3435
FALLBACK_STRATEGY_NAME = "basic_iterative_pi_gpt-5.5"
3536
DEFAULT_MAX_NATIVE_TEST_VERIFICATION_ITERATIONS = 40
3637
DEFAULT_NATIVE_TEST_VERIFICATION_BATCH_SIZE = 5
38+
DEFAULT_DYNAMIC_ACCESS_EXPLORE_PROMPT = "prompt_templates/dynamic_access/dynamic-access-explore.md"
3739

3840

3941
@WorkflowStrategy.register("dynamic_access_iterative")
@@ -55,6 +57,10 @@ class DynamicAccessIterativeStrategy(WorkflowStrategy):
5557

5658
def __init__(self, strategy_obj: dict, **context):
5759
super().__init__(strategy_obj, **context)
60+
# The per-class exploration anchor prompt is optional in the bundle: when
61+
# a strategy does not declare it, fall back to the packaged default so
62+
# existing configs keep working without listing the new prompt key.
63+
self.prompts.setdefault("dynamic-access-explore", DEFAULT_DYNAMIC_ACCESS_EXPLORE_PROMPT)
5864
self.library = self.context["library"]
5965
self.reachability_repo_path = self.context["reachability_repo_path"]
6066
self.keep_tests_without_dynamic_access = bool(self.context.get("keep_tests_without_dynamic_access", False))
@@ -360,6 +366,12 @@ def flush_native_test_batch(reason: str, active_class_name: str | None = None) -
360366
class_failed = False
361367
class_committed = False
362368
gate_failed = False
369+
# Build one exploration anchor per class, then fork it for each
370+
# attempt: the shared exploration is reused, but each attempt starts
371+
# from the same lean, pre-generation state instead of accumulating
372+
# the previous attempts' turns (§WF-dynamic-access-iterative-strategy).
373+
agent.clear_context()
374+
self._explore_class(agent, class_name, active_class, current_report)
363375
while class_attempts < self.max_class_iterations:
364376
self._print_dynamic_access_detail(
365377
"attempt {attempt}/{max_attempts}".format(
@@ -375,53 +387,55 @@ def flush_native_test_batch(reason: str, active_class_name: str | None = None) -
375387
dynamic_access_progress=self._format_progress(delta, class_attempts),
376388
uncovered_dynamic_access_calls=format_call_sites(active_class.uncovered_call_sites),
377389
)
378-
self._print_dynamic_access_detail("agent: running dynamic-access prompt", indent_level=2)
379-
agent.send_prompt(dynamic_prompt)
380-
self._print_dynamic_access_detail("agent: complete", indent_level=2)
381-
prompt_iterations += 1
382-
save_phase_update(
383-
self.continuation_marker_path,
384-
lambda marker: marker.record_iteration(PHASE_EXPLORE, prompt_iterations),
385-
)
386-
class_attempts += 1
387-
390+
self._print_dynamic_access_detail("agent: forking exploration anchor for generation", indent_level=2)
388391
reached_native_test = False
389392
last_test_output = ""
390393
last_failed_task = None
391-
for test_iteration in range(self.max_class_test_iterations):
392-
self._print_dynamic_access_detail(
393-
"test {current}/{maximum}: running ./gradlew test -Pcoordinates={library}".format(
394-
current=test_iteration + 1,
395-
maximum=self.max_class_test_iterations,
396-
library=self.library,
397-
),
398-
indent_level=2,
399-
)
400-
test_output = agent.run_test_command(f"./gradlew test -Pcoordinates={self.library}")
401-
failed_task = self._get_first_failed_task(test_output)
402-
last_test_output = test_output
403-
last_failed_task = failed_task
404-
self._print_dynamic_access_detail(
405-
"test: complete (failed task: {failed_task})".format(
406-
failed_task=failed_task or "none",
407-
),
408-
indent_level=2,
409-
)
410-
if failed_task in {"nativeTest", None}:
411-
reached_native_test = True
412-
break
413-
self._print_dynamic_access_detail(
414-
"agent: test failed before nativeTest; sending failure output back to agent",
415-
indent_level=2,
416-
)
417-
agent.send_prompt(
418-
"When `./gradlew test -Pcoordinates={library}` is ran this is the error:\n{error_output}".format(
419-
library=self.library,
420-
error_output=test_output,
421-
)
422-
)
394+
# Fork the anchor for this attempt. The child folds its own token
395+
# usage into the anchor when the `with` block exits, so no per-site
396+
# accounting is needed here (see Agent.fork / Agent.__exit__).
397+
with agent.fork(dynamic_prompt) as attempt_agent:
423398
self._print_dynamic_access_detail("agent: complete", indent_level=2)
424399
prompt_iterations += 1
400+
save_phase_update(
401+
self.continuation_marker_path,
402+
lambda marker: marker.record_iteration(PHASE_EXPLORE, prompt_iterations),
403+
)
404+
class_attempts += 1
405+
406+
for test_iteration in range(self.max_class_test_iterations):
407+
self._print_dynamic_access_detail(
408+
"test {current}/{maximum}: running ./gradlew test -Pcoordinates={library}".format(
409+
current=test_iteration + 1,
410+
maximum=self.max_class_test_iterations,
411+
library=self.library,
412+
),
413+
indent_level=2,
414+
)
415+
test_output = attempt_agent.run_test_command(f"./gradlew test -Pcoordinates={self.library}")
416+
failed_task = self._get_first_failed_task(test_output)
417+
last_test_output = test_output
418+
last_failed_task = failed_task
419+
self._print_dynamic_access_detail(
420+
"test: complete (failed task: {failed_task})".format(
421+
failed_task=failed_task or "none",
422+
),
423+
indent_level=2,
424+
)
425+
if failed_task in {"nativeTest", None}:
426+
reached_native_test = True
427+
break
428+
self._print_dynamic_access_detail(
429+
"agent: test failed before nativeTest; sending failure output back to agent",
430+
indent_level=2,
431+
)
432+
failure_message = (
433+
f"When `./gradlew test -Pcoordinates={self.library}` is ran "
434+
f"this is the error:\n{test_output}"
435+
)
436+
attempt_agent.send_prompt(failure_message)
437+
self._print_dynamic_access_detail("agent: complete", indent_level=2)
438+
prompt_iterations += 1
425439

426440
if not reached_native_test:
427441
# Tests failed for this class, roll back to before this class
@@ -550,8 +564,8 @@ def flush_native_test_batch(reason: str, active_class_name: str | None = None) -
550564
)
551565
active_class = updated_class
552566

553-
# Clear context between classes to keep the agent window focused.
554-
agent.clear_context()
567+
# The exploration anchor is rebuilt from scratch for the next class
568+
# (clear + explore at the top of the loop), so no clear is needed here.
555569
if gate_failed:
556570
self._print_failure_analysis(
557571
"native_test_verification_gate_failed",
@@ -666,6 +680,25 @@ def flush_native_test_batch(reason: str, active_class_name: str | None = None) -
666680
)
667681
return True, prompt_iterations
668682

683+
def _explore_class(self, agent, class_name: str, active_class, current_report) -> None:
684+
"""Prime a per-class exploration anchor that later attempts fork from.
685+
686+
This is the read-only exploration turn of
687+
§WF-dynamic-access-iterative-strategy: the agent studies the active class
688+
and the complete report and plans coverage, but writes nothing. Each
689+
attempt then forks this anchor so the exploration is reused without
690+
carrying prior attempts' turns forward.
691+
"""
692+
explore_prompt = self._render_prompt(
693+
"dynamic-access-explore",
694+
active_class_name=class_name,
695+
active_class_source_file=active_class.resolved_source_file or active_class.source_file or "N/A",
696+
dynamic_access_report=format_full_report(current_report),
697+
)
698+
self._print_dynamic_access_detail("agent: exploring class (no test generation)", indent_level=2)
699+
agent.send_prompt(explore_prompt)
700+
self._print_dynamic_access_detail("agent: exploration complete", indent_level=2)
701+
669702
def _mark_continuation_explore_completed(
670703
self,
671704
iteration: int,

forge/docs/workflows/dynamic-access.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ classes. Each selected class receives bounded prompt attempts, bounded test
3939
repair attempts, coverage-delta checks, per-class checkpointing, and native-test
4040
verification after each configured batch of coverage-gain commits.
4141

42+
Within a class, the engine first primes a per-class exploration anchor: a single
43+
read-only turn (the `dynamic-access-explore` prompt) that studies the active
44+
class and the complete report and plans coverage without writing any files. Each
45+
attempt then **forks** that anchor and sends only a slim generation prompt (the
46+
`dynamic-access-iteration` prompt) carrying the latest report delta, so the
47+
shared exploration is reused once instead of re-accumulating across attempts.
48+
Because a fork branches the conversation but not the working tree, the slim
49+
prompt instructs the fork to read the tests already on disk and extend them
50+
additively so it does not clobber coverage authored by a sibling attempt it has
51+
no memory of. Token usage from each discarded fork child is folded back into the
52+
anchor so run metrics stay accurate.
53+
4254
The iterative engine may return `RUN_STATUS_CHUNK_READY` when chunked issue
4355
orchestration gave it a class limit and the current chunk passed all local
4456
verification gates. Otherwise it returns success only when the dynamic-access
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Role: You are an expert JVM test engineer specializing in high-coverage integration testing for external libraries.
2+
3+
Task:
4+
Explore — do not write tests yet. This is a read-only planning turn for the active dynamic-access class in `{library}`. Subsequent turns will fork this session to generate the tests, so build a durable understanding here that those turns can rely on.
5+
6+
Source Context:
7+
{source_context_overview}
8+
9+
{resolved_edit_scope_context}
10+
11+
Issue-Requested Metadata:
12+
{issue_requested_metadata_context}
13+
14+
Library Preparation Preflight:
15+
{library_preparation_preflight_context}
16+
17+
Active class (focus your exploration here):
18+
- Class: {active_class_name}
19+
- Source file: {active_class_source_file}
20+
21+
Complete dynamic-access report for this library:
22+
{dynamic_access_report}
23+
24+
What to produce:
25+
- Read the active class source and any collaborators needed to reach its uncovered dynamic-access call sites.
26+
- Identify the public API entry points that drive execution to each uncovered call site of the active class.
27+
- Note the setup, fixtures, and inputs a test would need, and any call sites reachable only through broken or version-specific paths that should be left alone.
28+
- Summarize a concrete coverage plan for the active class that a follow-up test-writing turn can execute.
29+
30+
Rules:
31+
- Do NOT create, edit, or delete any files during this turn. Exploration only.
32+
- Focus on the active class; use the rest of the report only as context for shared setup or collaborators.
33+
- Prefer coverage through the library's normal public API. Do not plan to satisfy coverage by asserting a known broken, regressed, or version-specific failure path.

0 commit comments

Comments
 (0)