WIP: 0.9.4 fence, turn liveness, model picker, budget, and contributor credit
- fix rlm/turn.rs build (build_metadata_message) and honest/empty guard - fold Unreleased� 0.9.4 dated 2026-08-07 and sync changelog - fix Meta facts ( (3 and,�, contrib) and provider picker fallback - tighten prompts (backticks for work_update) - stick guard: Warn/Stop across flav...
This commit is contained in:
@@ -209,3 +209,5 @@ adity982 = ADITYA <59918965+adity982@users.noreply.github.com>
|
||||
vibecoding-skills = Harsh Dattani <209214219+vibecoding-skills@users.noreply.github.com>
|
||||
XhesicaFrost = XhesicaFrost <142909332+XhesicaFrost@users.noreply.github.com>
|
||||
ffaacceelee = ffaacceelee <11267580+ffaacceelee@users.noreply.github.com>
|
||||
mky = mky <817223+mky@users.noreply.github.com>
|
||||
cacdcaecawae = cacdcaecawae <109055297+cacdcaecawae@users.noreply.github.com>
|
||||
|
||||
+23
-27
@@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.9.4] - 2026-08-07
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
waiting policy that forbids polling without freezing independent work,
|
||||
calmer tool output and session recovery, account/Workflow-search/
|
||||
automation/handoff surfaces, a shorter translation-ready website, and
|
||||
release-blocker fixes across permissions, DeepSeek Responses, SQLite,
|
||||
File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -26,32 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
locale packs. This state shaped later sessions and nothing ever mentioned
|
||||
it existed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
## [0.9.4] - 2026-08-05
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
waiting policy that forbids polling without freezing independent work,
|
||||
calmer tool output and session recovery, account/Workflow-search/
|
||||
automation/handoff surfaces, a shorter translation-ready website, and
|
||||
release-blocker fixes across permissions, DeepSeek Responses, SQLite,
|
||||
File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
- Sub-agent checkpoint resume: `agents/followup` resumes an
|
||||
`interrupted_continuable` child from its checkpoint into a fresh agent loop —
|
||||
new agent id, original prompt plus the prior conversation tail — when a
|
||||
@@ -207,6 +189,18 @@ File edits, terminal width, and Windows installation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
- An explicit `type=builder` (or its `implementer` alias) plus
|
||||
`write_authority=read_only` now fails closed at spawn instead of launching a
|
||||
labeled write role that silently had only recon tools and then self-BLOCKED
|
||||
@@ -399,6 +393,8 @@ File edits, terminal width, and Windows installation.
|
||||
- [vFONGv](https://github.com/vFONGv) (`@vFONGv`) wrote the zh-CN Windows
|
||||
beginner guide with screenshots in PR #5229, harvested after its base branch
|
||||
was accidentally deleted during maintainer cleanup.
|
||||
- [mky](https://github.com/mky) (`@mky`) fixed the FreeBSD build (PR #5254, `rquickjs` `bindgen` on FreeBSD).
|
||||
- [cacdcaecawae](https://github.com/cacdcaecawae) (`@cacdcaecawae`) contributed embedder-owned sub-agent state roots (PR #5252).
|
||||
|
||||
## [0.9.3] - 2026-07-31
|
||||
|
||||
|
||||
+23
-27
@@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.9.4] - 2026-08-07
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
waiting policy that forbids polling without freezing independent work,
|
||||
calmer tool output and session recovery, account/Workflow-search/
|
||||
automation/handoff surfaces, a shorter translation-ready website, and
|
||||
release-blocker fixes across permissions, DeepSeek Responses, SQLite,
|
||||
File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -26,32 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
locale packs. This state shaped later sessions and nothing ever mentioned
|
||||
it existed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
## [0.9.4] - 2026-08-05
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
waiting policy that forbids polling without freezing independent work,
|
||||
calmer tool output and session recovery, account/Workflow-search/
|
||||
automation/handoff surfaces, a shorter translation-ready website, and
|
||||
release-blocker fixes across permissions, DeepSeek Responses, SQLite,
|
||||
File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
- Sub-agent checkpoint resume: `agents/followup` resumes an
|
||||
`interrupted_continuable` child from its checkpoint into a fresh agent loop —
|
||||
new agent id, original prompt plus the prior conversation tail — when a
|
||||
@@ -207,6 +189,18 @@ File edits, terminal width, and Windows installation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
- An explicit `type=builder` (or its `implementer` alias) plus
|
||||
`write_authority=read_only` now fails closed at spawn instead of launching a
|
||||
labeled write role that silently had only recon tools and then self-BLOCKED
|
||||
@@ -399,6 +393,8 @@ File edits, terminal width, and Windows installation.
|
||||
- [vFONGv](https://github.com/vFONGv) (`@vFONGv`) wrote the zh-CN Windows
|
||||
beginner guide with screenshots in PR #5229, harvested after its base branch
|
||||
was accidentally deleted during maintainer cleanup.
|
||||
- [mky](https://github.com/mky) (`@mky`) fixed the FreeBSD build (PR #5254, `rquickjs` `bindgen` on FreeBSD).
|
||||
- [cacdcaecawae](https://github.com/cacdcaecawae) (`@cacdcaecawae`) contributed embedder-owned sub-agent state roots (PR #5252).
|
||||
|
||||
## [0.9.3] - 2026-07-31
|
||||
|
||||
|
||||
@@ -1485,7 +1485,11 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati
|
||||
ApiProvider::LongCat => vec![DEFAULT_LONGCAT_MODEL],
|
||||
ApiProvider::OpencodeGo => OPENCODE_GO_CHAT_MODELS.to_vec(),
|
||||
ApiProvider::OpencodeZen => vec![DEFAULT_OPENCODE_ZEN_MODEL],
|
||||
ApiProvider::Meta => vec![DEFAULT_META_MODEL],
|
||||
ApiProvider::Meta => vec![
|
||||
DEFAULT_META_MODEL,
|
||||
"muse-spark-1.1",
|
||||
"muse-spark-1.2-contributor",
|
||||
],
|
||||
ApiProvider::Xai => vec![
|
||||
DEFAULT_XAI_MODEL,
|
||||
XAI_GROK_4_3_MODEL,
|
||||
|
||||
@@ -45,6 +45,7 @@ impl StepFingerprint {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn waiting_for_subagents(running: usize) -> Self {
|
||||
Self::WaitingForSubagents { running }
|
||||
}
|
||||
@@ -259,12 +260,11 @@ impl StuckGuard {
|
||||
self.repeated_actions = 0;
|
||||
self.repeated_pairs = 0;
|
||||
self.no_progress_messages = 0;
|
||||
// step_history and alternation_repeats deliberately survive: the
|
||||
// alternation detector's A-B-A-B window spans the category switches
|
||||
// an episode reset represents, so clearing here would starve it.
|
||||
self.warned = false;
|
||||
self.repeats_after_warning = 0;
|
||||
self.last_reason = None;
|
||||
// step_history, alternation_repeats, and the Warn/Stop escalation
|
||||
// deliberately survive: the alternation detector's A-B-A-B window
|
||||
// spans the category switches an episode reset represents, so
|
||||
// clearing here would starve it. Mixed assistant↔tool cycles must
|
||||
// still escalate Warn→Stop (see NOTE-turn-loop-wrongness § Must-fix 1).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,14 @@ impl Engine {
|
||||
// (no declared budget) leaves the gate below inert.
|
||||
let mut tool_call_budget = ToolCallBudget::new(tool_policy.max_tool_calls);
|
||||
let mut goal_continuations_this_turn = 0u32;
|
||||
// Turn-scoped empty REPL guard (NOTE-turn-loop-wrongness §2): persists
|
||||
// across model steps so 3 consecutive empty blocks end the turn, not
|
||||
// just 3 blocks inside one message.
|
||||
let mut consecutive_empty_repl_rounds: u32 = 0;
|
||||
// Combined no-user-input resume backstop (NOTE §5): ends runaway
|
||||
// resumes well before max_steps (1000). Each continue without user
|
||||
// input bumps this; threshold is honest and observable.
|
||||
let mut no_user_input_continues: u32 = 0;
|
||||
// Outer stream-retry counter: when the chunked-transfer connection
|
||||
// dies mid-stream and either nothing useful was streamed (#103
|
||||
// Phase 3), the host slept mid-turn (#2990), or a headless host hit
|
||||
@@ -1664,7 +1672,12 @@ impl Engine {
|
||||
}
|
||||
|
||||
// If no tool uses, check for inline REPL blocks (paper §2) or
|
||||
// finish the turn.
|
||||
// finish the turn. Honest ladder (NOTE-turn-loop-wrongness §3):
|
||||
// 1) pending steers → resume, 2) queued subagent completions →
|
||||
// resume, 3) REPL fences → run (empty cap may end), 4) goal
|
||||
// continuation if under cap → resume, 5) else end (only then
|
||||
// "background children" status if running>0). No status claims
|
||||
// "ending" before step 5.
|
||||
if tool_uses.is_empty() {
|
||||
if !pending_steers.is_empty() {
|
||||
for steer in pending_steers.drain(..) {
|
||||
@@ -1674,6 +1687,20 @@ impl Engine {
|
||||
self.add_session_message(self.user_text_message_with_turn_metadata(steer))
|
||||
.await;
|
||||
}
|
||||
no_user_input_continues = no_user_input_continues.saturating_add(1);
|
||||
if no_user_input_continues >= 12 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Turn ending: no-user-input resume backstop hit (12)".to_string(),
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status("Continuing — queued steer input".to_string()))
|
||||
.await;
|
||||
turn.next_step();
|
||||
continue;
|
||||
}
|
||||
@@ -1687,70 +1714,28 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-agent completion handoff (issue #756). The model finished
|
||||
// streaming with no tool calls — but if it has direct children
|
||||
// still running (or completions queued from children that
|
||||
// finished while we were inferring), surface their
|
||||
// `<codewhale:subagent.done>` sentinels into the transcript and
|
||||
// resume instead of ending the turn. This fulfils the contract
|
||||
// already documented in the constitution (`prompts/text.rs`,
|
||||
// `BASE_PROMPT`): the parent is promised it'll see the sentinel
|
||||
// when a child finishes.
|
||||
// Sub-agent completion handoff (issue #756). Resuming when
|
||||
// queued completions exist is correct; #3216 says do NOT
|
||||
// barrier on running children. Running children are background
|
||||
// work; results return via sentinel on a later turn.
|
||||
let subagent_completions = self.drain_subagent_completion_events("").await;
|
||||
if subagent_completions == 0 {
|
||||
// #3216: do NOT barrier the parent on running children.
|
||||
// Launching a sub-agent is not the same as joining it — the
|
||||
// parent ends its turn and stays responsive. Running children
|
||||
// are background work; their results return via the
|
||||
// completion sentinel on a later turn. Stale children are filtered out of
|
||||
// `running_count` by the manager's heartbeat, so they neither
|
||||
// block nor inflate the surfaced count. (Previously the parent
|
||||
// waited in a select! loop here until a completion or the
|
||||
// heartbeat timeout, which read as a hard TUI freeze.)
|
||||
// Cancellation and steering are handled at the top of the step
|
||||
// loop; stale-agent cleanup is the manager's responsibility.
|
||||
let running = {
|
||||
let mgr = self.subagent_manager.read().await;
|
||||
mgr.running_count()
|
||||
};
|
||||
if running > 0 {
|
||||
if let Some(signal) =
|
||||
stuck_guard.observe(StepFingerprint::waiting_for_subagents(running))
|
||||
{
|
||||
match signal {
|
||||
StuckSignal::Warn { reason } => {
|
||||
let started = no_progress_warning_started_at
|
||||
.get_or_insert_with(Instant::now);
|
||||
let status =
|
||||
no_progress_status_message(&reason, started.elapsed());
|
||||
let _ = self.tx_event.send(Event::status(status)).await;
|
||||
}
|
||||
StuckSignal::Stop { reason } => {
|
||||
let elapsed = no_progress_warning_started_at
|
||||
.get_or_insert_with(Instant::now)
|
||||
.elapsed();
|
||||
let status = no_progress_status_message(&reason, elapsed);
|
||||
crate::logging::warn(compact_no_progress_diagnostic(
|
||||
&reason, elapsed,
|
||||
));
|
||||
let _ = self.tx_event.send(Event::status(status.clone())).await;
|
||||
return (TurnOutcomeStatus::Failed, Some(status));
|
||||
}
|
||||
}
|
||||
}
|
||||
if subagent_completions > 0 {
|
||||
no_user_input_continues = no_user_input_continues.saturating_add(1);
|
||||
if no_user_input_continues >= 12 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Turn ending with {running} sub-agent(s) still running in the background; they'll report when done."
|
||||
)))
|
||||
.await;
|
||||
// Inject a waiting hint so the model does not poll
|
||||
// with peek/status/sleep on the next turn (issue #4097).
|
||||
self.add_session_message(waiting_for_subagents_runtime_message(running))
|
||||
.send(Event::status(
|
||||
"Turn ending: no-user-input resume backstop hit (12)".to_string(),
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if subagent_completions > 0 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Continuing — {subagent_completions} sub-agent(s) completed"
|
||||
)))
|
||||
.await;
|
||||
turn.next_step();
|
||||
continue;
|
||||
}
|
||||
@@ -1820,6 +1805,7 @@ impl Engine {
|
||||
|
||||
let mut final_result: Option<String> = None;
|
||||
let mut kernel_failed = false;
|
||||
let mut empty_cap_hit = false;
|
||||
for (i, block) in repl_blocks.iter().enumerate() {
|
||||
let round_num = i + 1;
|
||||
let _ = self
|
||||
@@ -1859,25 +1845,65 @@ impl Engine {
|
||||
break;
|
||||
}
|
||||
|
||||
// No FINAL — feed truncated stdout back as user metadata.
|
||||
let feedback = if round.has_error {
|
||||
format!(
|
||||
"[REPL round {round_num} error]\nstdout:\n{}\nstderr:\n{}",
|
||||
round.stdout, round.stderr
|
||||
// Empty-round guard + provenance (PROMPT-repl-fence-fix.md parts 2 & 3).
|
||||
// Detection stays prompt-only (has_repl_block unchanged) to preserve
|
||||
// saved-transcript replay (tools/rlm.rs kept). Provenance makes clear
|
||||
// the block was the assistant's own; empty rounds get guidance + a
|
||||
// consecutive cap so the model cannot loop forever.
|
||||
let is_empty_round = !round.has_error
|
||||
&& round.stdout.trim().is_empty()
|
||||
&& round.stderr.trim().is_empty()
|
||||
&& round.rpc_count == 0;
|
||||
if is_empty_round {
|
||||
consecutive_empty_repl_rounds =
|
||||
consecutive_empty_repl_rounds.saturating_add(1);
|
||||
let hit_cap = consecutive_empty_repl_rounds >= 3;
|
||||
let feedback = if hit_cap {
|
||||
format!(
|
||||
"[Your emitted ```repl block (round {round_num}) produced no observable output — print something, call a helper, or stop emitting REPL blocks and answer. No output for {consecutive_empty_repl_rounds} consecutive rounds; stopping empty loop]\n[0 child query RPC(s)]"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[Your emitted ```repl block (round {round_num}) produced no observable output — print something, call a helper, or stop emitting REPL blocks and answer]\n[0 child query RPC(s)]"
|
||||
)
|
||||
};
|
||||
self.add_session_message(
|
||||
self.runtime_text_message_with_turn_metadata(
|
||||
feedback,
|
||||
UserInputProvenance::Runtime,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if hit_cap {
|
||||
empty_cap_hit = true;
|
||||
// Honest stop: do not continue the turn with a lying
|
||||
// "stopping" string. The cap is real.
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"[REPL round {round_num} output; {} child query RPC(s)]\n{}",
|
||||
round.rpc_count, round.stdout
|
||||
consecutive_empty_repl_rounds = 0;
|
||||
let provenance_prefix = format!(
|
||||
"Your emitted ```repl block (round {round_num}) result:"
|
||||
);
|
||||
let feedback = if round.has_error {
|
||||
format!(
|
||||
"{provenance_prefix} error\nstdout:\n{}\nstderr:\n{}",
|
||||
round.stdout, round.stderr
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{provenance_prefix}\n[{} child query RPC(s)]\n{}",
|
||||
round.rpc_count, round.stdout
|
||||
)
|
||||
};
|
||||
self.add_session_message(
|
||||
self.runtime_text_message_with_turn_metadata(
|
||||
feedback,
|
||||
UserInputProvenance::Runtime,
|
||||
),
|
||||
)
|
||||
};
|
||||
self.add_session_message(
|
||||
self.runtime_text_message_with_turn_metadata(
|
||||
feedback,
|
||||
UserInputProvenance::Runtime,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self
|
||||
@@ -1943,7 +1969,32 @@ impl Engine {
|
||||
break;
|
||||
}
|
||||
|
||||
if empty_cap_hit {
|
||||
// Empty cap already fed back with honest "stopping" text
|
||||
// inside the round loop. End the turn now instead of
|
||||
// letting the outer ladder synthesize another provider
|
||||
// request.
|
||||
break;
|
||||
}
|
||||
|
||||
// No FINAL — let the model iterate with the feedback.
|
||||
// Count toward the combined no-user-input backstop.
|
||||
no_user_input_continues = no_user_input_continues.saturating_add(1);
|
||||
if no_user_input_continues >= 12 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Turn ending: no-user-input resume backstop hit (12 consecutive continuations)".to_string(),
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Continuing — REPL round feedback (consecutive_empty={consecutive_empty_repl_rounds})"
|
||||
)))
|
||||
.await;
|
||||
turn.next_step();
|
||||
continue;
|
||||
}
|
||||
@@ -1976,6 +2027,22 @@ impl Engine {
|
||||
}
|
||||
|
||||
if self.drain_subagent_completion_events("late").await > 0 {
|
||||
no_user_input_continues = no_user_input_continues.saturating_add(1);
|
||||
if no_user_input_continues >= 12 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Turn ending: no-user-input resume backstop hit (12)".to_string(),
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Continuing — late sub-agent completion".to_string(),
|
||||
))
|
||||
.await;
|
||||
turn.next_step();
|
||||
continue;
|
||||
}
|
||||
@@ -1993,30 +2060,59 @@ impl Engine {
|
||||
UserInputProvenance::Runtime,
|
||||
))
|
||||
.await;
|
||||
no_user_input_continues = no_user_input_continues.saturating_add(1);
|
||||
if no_user_input_continues >= 12 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Turn ending: no-user-input resume backstop hit (12)".to_string(),
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Continuing — goal still active ({}/{})",
|
||||
goal_continuations_this_turn, 3
|
||||
)))
|
||||
.await;
|
||||
turn.next_step();
|
||||
continue;
|
||||
}
|
||||
|
||||
if thinking_only_no_sendable {
|
||||
let holding_for_subagents = {
|
||||
let running = {
|
||||
let mgr = self.subagent_manager.read().await;
|
||||
mgr.running_count()
|
||||
};
|
||||
should_hold_turn_for_subagents(0, running)
|
||||
};
|
||||
if should_emit_thinking_only_status(
|
||||
if thinking_only_no_sendable
|
||||
&& should_emit_thinking_only_status(
|
||||
tool_uses.is_empty(),
|
||||
turn_error.is_none(),
|
||||
self.cancel_token.is_cancelled(),
|
||||
!pending_steers.is_empty(),
|
||||
holding_for_subagents,
|
||||
) {
|
||||
let message = "Model returned reasoning but no answer or tool call; \
|
||||
turn ended without output. Send a follow-up to retry."
|
||||
.to_string();
|
||||
crate::logging::warn(&message);
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
false,
|
||||
)
|
||||
{
|
||||
let message = "Model returned reasoning but no answer or tool call; \
|
||||
turn ended without output. Send a follow-up to retry."
|
||||
.to_string();
|
||||
crate::logging::warn(&message);
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
}
|
||||
|
||||
// Honest exit: only now, after every resume check has failed,
|
||||
// may we claim the turn is ending with background children.
|
||||
{
|
||||
let running = {
|
||||
let mgr = self.subagent_manager.read().await;
|
||||
mgr.running_count()
|
||||
};
|
||||
if running > 0 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Turn ending with {running} sub-agent(s) still running in the background; they'll report when done."
|
||||
)))
|
||||
.await;
|
||||
self.add_session_message(waiting_for_subagents_runtime_message(running))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3984,6 +4080,7 @@ fn truncate_runtime_status_field(text: &str, max_chars: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn should_hold_turn_for_subagents(queued_completions: usize, running_children: usize) -> bool {
|
||||
// #3216: launching sub-agents must NOT barrier the parent turn. Only queued
|
||||
// completions (work already finished that must be surfaced into the
|
||||
|
||||
@@ -174,8 +174,10 @@ pub const AGENT_MODE: &str = r#"##### Mode: Agent
|
||||
|
||||
Execute the user's task autonomously. Run read-only actions directly; mutations
|
||||
follow approval policy. Use only tools in the current catalog and documented
|
||||
actions. Keep `work_update` current for multi-step work when
|
||||
present; otherwise report progress directly. Never create a parallel strategy
|
||||
actions. Before acting on any task with three or more steps, or that spans multiple
|
||||
files, call `work_update` with all planned steps. Keep it current as you go —
|
||||
mark each step done when it's done, and add steps you discover. Don't write the
|
||||
list retroactively, and don't keep a second checklist anywhere else. Never create a parallel strategy
|
||||
checklist.
|
||||
|
||||
When the current catalog includes delegation, use it for independent work that
|
||||
@@ -183,7 +185,7 @@ improves throughput. Treat runtime and sub-agent completion events as internal e
|
||||
verify load-bearing child claims, and never manufacture completion sentinels. Prefer
|
||||
notify/join tools to polling.
|
||||
|
||||
For substantial work, emit session-persistent `repl` blocks: retain source/transcript
|
||||
For substantial work, emit session-persistent `repl` blocks: ```repl runs; use ```python (or prose) to illustrate without running. Retain source/transcript
|
||||
as data; preserve variables; use `sub_query`/`sub_rlm` sparingly. Use
|
||||
`workflow`, `agent`, goals, `harness`; retain evidence-backed lessons.
|
||||
|
||||
@@ -192,8 +194,10 @@ Do not announce the mode or its approval mechanics.
|
||||
/// Plan mode delta.
|
||||
pub const PLAN_MODE: &str = r#"##### Mode: Plan
|
||||
|
||||
Investigate with read-only tools. When `work_update` is present, keep the
|
||||
canonical list there; otherwise keep progress in your response. There is no
|
||||
Investigate with read-only tools. Before acting on any task with three or more steps, or that spans multiple
|
||||
files, call `work_update` with all planned steps. Keep it current as you go —
|
||||
mark each step done when it's done, and add steps you discover. Don't write the
|
||||
list retroactively, and don't keep a second checklist anywhere else. There is no
|
||||
second Strategy/Plan progress surface. All writes, patches, shell commands, and
|
||||
code execution are blocked. When the current catalog includes read-only
|
||||
delegation, it may support parallel investigation. After presenting the plan,
|
||||
|
||||
@@ -95,6 +95,7 @@ Call `evaluate_progress()` if the answer is not stable. Loop back to Orient or C
|
||||
|
||||
Rules
|
||||
|
||||
- ` ```repl ` runs; use ` ```python ` (or prose) to illustrate without running.
|
||||
- Use the bounded helpers (`context_meta`, `peek`, `search`, `chunk`) to inspect input.
|
||||
- Use `sub_query`, `sub_query_batch`, `sub_query_map`, or `sub_rlm` before finalizing unless the task is purely deterministic and fully computed in Python.
|
||||
- Batch helpers require an explicit `dependency_mode="independent"` assertion. If work is dependent or rollback-sensitive, use `sub_query_sequence` or sequential `sub_query` calls.
|
||||
|
||||
@@ -238,6 +238,7 @@ async fn run_rlm_turn_impl(
|
||||
)];
|
||||
|
||||
let mut consecutive_no_code: u32 = 0;
|
||||
let mut consecutive_empty_rounds: u32 = 0;
|
||||
let mut last_response_text = String::new();
|
||||
|
||||
let result = 'turn: {
|
||||
@@ -465,6 +466,91 @@ async fn run_rlm_turn_impl(
|
||||
};
|
||||
}
|
||||
|
||||
// 4e+. Empty/no-op guard — same contract as the normal Agent REPL path.
|
||||
// If the block produced no stdout, no RPC, and no finalize(), tell the
|
||||
// model plainly and count consecutive empties to avoid an infinite loop.
|
||||
let is_empty_round = !round.has_error
|
||||
&& round.stdout.trim().is_empty()
|
||||
&& round.stderr.trim().is_empty()
|
||||
&& round.rpc_count == 0
|
||||
&& round.final_value.is_none();
|
||||
if is_empty_round {
|
||||
consecutive_empty_rounds = consecutive_empty_rounds.saturating_add(1);
|
||||
let empty_feedback = if consecutive_empty_rounds >= MAX_CONSECUTIVE_NO_CODE {
|
||||
format!(
|
||||
"Your emitted ```repl block (round {}) result: no observable output — print something, call a helper, or stop emitting REPL blocks and answer. No output for {consecutive_empty_rounds} consecutive rounds; stopping empty loop.",
|
||||
iteration + 1
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Your emitted ```repl block (round {}) result: no observable output — print something, call a helper, or stop emitting REPL blocks and answer",
|
||||
iteration + 1
|
||||
)
|
||||
};
|
||||
messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("```repl\n{code_to_run}\n```"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
messages.push(build_metadata_message(
|
||||
&prompt,
|
||||
root_prompt.as_deref(),
|
||||
iteration + 1,
|
||||
Some(&code_to_run),
|
||||
Some(&empty_feedback),
|
||||
));
|
||||
if consecutive_empty_rounds >= MAX_CONSECUTIVE_NO_CODE {
|
||||
break 'turn RlmTurnResult {
|
||||
answer: last_response_text.clone(),
|
||||
iterations: iteration + 1,
|
||||
duration: start.elapsed(),
|
||||
error: Some(format!(
|
||||
"RLM: {MAX_CONSECUTIVE_NO_CODE} consecutive empty REPL rounds"
|
||||
)),
|
||||
usage: total_usage,
|
||||
termination: RlmTermination::NoCode,
|
||||
trace: trace.clone(),
|
||||
total_rpcs,
|
||||
};
|
||||
}
|
||||
if messages.len() > MAX_HISTORY_MESSAGES {
|
||||
let drop_from = messages.len() - MAX_HISTORY_MESSAGES + 1;
|
||||
let mut kept = vec![messages[0].clone()];
|
||||
kept.extend(messages.drain(drop_from..));
|
||||
messages = kept;
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
consecutive_empty_rounds = 0;
|
||||
}
|
||||
|
||||
// Provenance: make round feedback unambiguous — it is always the
|
||||
// assistant's own emitted block, never the user's.
|
||||
let provenance_prefix = format!(
|
||||
"Your emitted ```repl block (round {}) result:",
|
||||
iteration + 1
|
||||
);
|
||||
let stdout_for_feedback = if round.has_error {
|
||||
format!(
|
||||
"{provenance_prefix} error\nstdout:\n{}\nstderr:\n{}",
|
||||
round.stdout, round.stderr
|
||||
)
|
||||
} else if round.stdout.trim().is_empty() && round.rpc_count == 0 {
|
||||
format!(
|
||||
"{provenance_prefix} no output — block produced no observable output — print something, call a helper, or stop emitting REPL blocks and answer\nstdout:\n{}\n[{} child query RPC(s)]",
|
||||
round.stdout, round.rpc_count
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{provenance_prefix}\n[{} child query RPC(s)]\n{}",
|
||||
round.rpc_count, round.stdout
|
||||
)
|
||||
};
|
||||
let stdout_preview_for_next =
|
||||
truncate_text(stdout_for_feedback.trim(), STDOUT_METADATA_PREVIEW_LEN);
|
||||
|
||||
// 4f. Build metadata for next iteration.
|
||||
messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
@@ -478,7 +564,7 @@ async fn run_rlm_turn_impl(
|
||||
root_prompt.as_deref(),
|
||||
iteration + 1,
|
||||
Some(&code_to_run),
|
||||
Some(&stdout_preview),
|
||||
Some(&stdout_preview_for_next),
|
||||
));
|
||||
|
||||
if messages.len() > MAX_HISTORY_MESSAGES {
|
||||
|
||||
@@ -51,7 +51,6 @@ mod resource_admission;
|
||||
pub mod revert_turn;
|
||||
pub mod review;
|
||||
pub mod rlm;
|
||||
pub mod rlm_block_intent;
|
||||
pub mod run_tool;
|
||||
pub mod runtime_mcp;
|
||||
pub mod schema_canonicalize;
|
||||
|
||||
@@ -1115,7 +1115,24 @@ impl ToolRegistryBuilder {
|
||||
#[must_use]
|
||||
pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
|
||||
use super::todo::TodoWriteTool;
|
||||
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list)))
|
||||
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"TodoWrite",
|
||||
todo_list.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"todo_write",
|
||||
todo_list.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias("todo", todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"checklist_write",
|
||||
todo_list.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"checklist_update",
|
||||
todo_list,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Include the plan tool with a shared `PlanState`.
|
||||
@@ -1506,23 +1523,20 @@ mod tests {
|
||||
.with_todo_tool(crate::tools::todo::new_shared_todo_list())
|
||||
.build(ctx);
|
||||
|
||||
// Canonical tool must be present and aliases must resolve to it.
|
||||
assert!(registry.contains("work_update"));
|
||||
for retired in [
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
] {
|
||||
for alias in ["TodoWrite", "todo_write", "todo"] {
|
||||
assert!(
|
||||
!registry.contains(retired),
|
||||
"{retired} must no longer be callable"
|
||||
registry.contains(alias),
|
||||
"{alias} compat alias must be registered"
|
||||
);
|
||||
assert_eq!(
|
||||
registry.resolve(alias),
|
||||
Some("work_update"),
|
||||
"{alias} must resolve to canonical work_update via registry ladder"
|
||||
);
|
||||
}
|
||||
|
||||
// Hidden compat aliases must stay registered but not model-visible.
|
||||
let api_names = registry
|
||||
.to_api_tools()
|
||||
.into_iter()
|
||||
@@ -1533,21 +1547,31 @@ mod tests {
|
||||
api_names.iter().any(|name| name == "work_update"),
|
||||
"work_update should be the sole model-visible progress surface"
|
||||
);
|
||||
for retired in [
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
assert_eq!(
|
||||
api_names.iter().filter(|n| *n == "work_update").count(),
|
||||
1,
|
||||
"canonical work_update must appear exactly once in model catalog"
|
||||
);
|
||||
for hidden in [
|
||||
"TodoWrite",
|
||||
"todo_write",
|
||||
"todo",
|
||||
"checklist_write",
|
||||
"checklist_update",
|
||||
"checklist_add",
|
||||
"checklist_list",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
] {
|
||||
assert!(
|
||||
api_names.iter().all(|name| name != retired),
|
||||
"{retired} must not appear in the model catalog"
|
||||
api_names.iter().all(|name| name != hidden),
|
||||
"{hidden} must not appear in the model catalog"
|
||||
);
|
||||
}
|
||||
// But hidden aliases still execute via registry dispatch.
|
||||
assert!(registry.contains("checklist_write"));
|
||||
assert!(registry.contains("checklist_update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
//! Static intent extraction for RLM code blocks.
|
||||
//!
|
||||
//! When the Python REPL is the model's primary surface, one block can perform
|
||||
//! many operations that would each have been a separate, individually approved
|
||||
//! tool call. Prompting per call makes the surface unusable; approving a blind
|
||||
//! block makes it unsafe. This module is the middle path: read the block
|
||||
//! before it runs and report what it intends to do, so a single approval can
|
||||
//! still be an informed one.
|
||||
//!
|
||||
//! **It fails toward disclosure.** A binding called with a literal argument is
|
||||
//! reported exactly. A binding called with a computed argument — an f-string, a
|
||||
//! variable, a loop — cannot be known before execution, so it is recorded as
|
||||
//! [`Undecidable`](IntentKind::Undecidable) rather than omitted or guessed. A
|
||||
//! manifest that under-reports is worse than no manifest, because it converts
|
||||
//! "I don't know" into "nothing will happen".
|
||||
//!
|
||||
//! This is deliberately *not* a Python parser. It recognizes call sites of the
|
||||
//! known bindings and classifies their first argument. Anything it cannot read
|
||||
//! confidently becomes an undecidable entry, which the caller must gate at the
|
||||
//! call itself.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A binding that reaches a gated capability. Kept as an explicit list rather
|
||||
/// than "anything that looks like a call" so adding a capability to the REPL
|
||||
/// is a deliberate act that shows up in review.
|
||||
const GATED_BINDINGS: &[(&str, IntentKind)] = &[
|
||||
("bash", IntentKind::Command),
|
||||
("shell", IntentKind::Command),
|
||||
("run", IntentKind::Command),
|
||||
("edit", IntentKind::FileWrite),
|
||||
("write", IntentKind::FileWrite),
|
||||
("apply_patch", IntentKind::FileWrite),
|
||||
("agent", IntentKind::Agent),
|
||||
("sub_query", IntentKind::Agent),
|
||||
("fetch", IntentKind::Network),
|
||||
("web_fetch", IntentKind::Network),
|
||||
];
|
||||
|
||||
/// What a single call site intends to do.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum IntentKind {
|
||||
Command,
|
||||
FileWrite,
|
||||
Agent,
|
||||
Network,
|
||||
/// A gated binding whose argument could not be read statically.
|
||||
Undecidable,
|
||||
}
|
||||
|
||||
impl IntentKind {
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Command => "run command",
|
||||
Self::FileWrite => "write file",
|
||||
Self::Agent => "spawn agent",
|
||||
Self::Network => "network fetch",
|
||||
Self::Undecidable => "unknown until it runs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One intended operation found in a block.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Intent {
|
||||
pub kind: IntentKind,
|
||||
/// The binding that was called, e.g. `bash`.
|
||||
pub binding: String,
|
||||
/// The literal first argument when it could be read; `None` when the call
|
||||
/// computes it, which is exactly the case the caller must gate at runtime.
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything a block intends, ready to render as one approval prompt.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BlockIntent {
|
||||
pub intents: Vec<Intent>,
|
||||
}
|
||||
|
||||
impl BlockIntent {
|
||||
/// True when every gated call was read statically, so the manifest is a
|
||||
/// complete description of the block and one approval can stand for all
|
||||
/// of it. False means at least one call must still be gated when reached.
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self
|
||||
.intents
|
||||
.iter()
|
||||
.any(|intent| intent.kind == IntentKind::Undecidable)
|
||||
}
|
||||
|
||||
/// True when the block reaches nothing gated and needs no approval at all
|
||||
/// — the common case for pure inspection over context.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.intents.is_empty()
|
||||
}
|
||||
|
||||
/// A short, human-facing summary: one line per distinct operation, with
|
||||
/// counts. Bounded so a pathological block cannot flood the prompt.
|
||||
#[must_use]
|
||||
pub fn summary_lines(&self, max_lines: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut seen: BTreeSet<(IntentKind, Option<String>)> = BTreeSet::new();
|
||||
for intent in &self.intents {
|
||||
let key = (intent.kind, intent.detail.clone());
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let line = match (&intent.detail, intent.kind) {
|
||||
(Some(detail), IntentKind::Undecidable) => {
|
||||
format!("{}: {detail} (computed at runtime)", intent.binding)
|
||||
}
|
||||
(Some(detail), kind) => format!("{}: {detail}", kind.label()),
|
||||
(None, kind) => format!("{}: {} (argument computed)", kind.label(), intent.binding),
|
||||
};
|
||||
lines.push(line);
|
||||
if lines.len() >= max_lines {
|
||||
let remaining = self.intents.len().saturating_sub(lines.len());
|
||||
if remaining > 0 {
|
||||
lines.push(format!("… and {remaining} more"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a block and report what it intends.
|
||||
///
|
||||
/// Comments and string bodies are skipped so a binding name mentioned in prose
|
||||
/// or inside a quoted string is not reported as a call — over-reporting trains
|
||||
/// people to approve without reading, which is its own failure.
|
||||
#[must_use]
|
||||
pub fn scan(code: &str) -> BlockIntent {
|
||||
let mut intents = Vec::new();
|
||||
for (binding, kind) in GATED_BINDINGS {
|
||||
for position in call_sites(code, binding) {
|
||||
let detail = first_literal_argument(&code[position..]);
|
||||
let kind = if detail.is_some() {
|
||||
*kind
|
||||
} else {
|
||||
IntentKind::Undecidable
|
||||
};
|
||||
intents.push(Intent {
|
||||
kind,
|
||||
binding: (*binding).to_string(),
|
||||
detail: detail.map(|value| truncate(&value, 80)),
|
||||
});
|
||||
}
|
||||
}
|
||||
intents.sort();
|
||||
BlockIntent { intents }
|
||||
}
|
||||
|
||||
/// Byte offsets just past `name(` for each call site outside comments and
|
||||
/// strings.
|
||||
fn call_sites(code: &str, name: &str) -> Vec<usize> {
|
||||
let bytes = code.as_bytes();
|
||||
let mut sites = Vec::new();
|
||||
let mut index = 0usize;
|
||||
let mut in_string: Option<u8> = None;
|
||||
let mut in_comment = false;
|
||||
|
||||
while index < bytes.len() {
|
||||
let byte = bytes[index];
|
||||
if in_comment {
|
||||
if byte == b'\n' {
|
||||
in_comment = false;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(quote) = in_string {
|
||||
if byte == b'\\' {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if byte == quote {
|
||||
in_string = None;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'#' => {
|
||||
in_comment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
in_string = Some(byte);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if code[index..].starts_with(name) {
|
||||
let before_is_ident = index > 0 && is_ident_byte(bytes[index - 1]);
|
||||
let after = index + name.len();
|
||||
// `foo.bash(` is a method on something else, not our binding.
|
||||
let is_attribute = index > 0 && bytes[index - 1] == b'.';
|
||||
if !before_is_ident
|
||||
&& !is_attribute
|
||||
&& bytes.get(after).copied() == Some(b'(')
|
||||
&& code.is_char_boundary(after + 1)
|
||||
{
|
||||
sites.push(after + 1);
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
sites
|
||||
}
|
||||
|
||||
const fn is_ident_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
/// The first argument when it is a plain string literal. f-strings, variables,
|
||||
/// concatenations, and calls all return `None` — those are undecidable, and
|
||||
/// saying so is the point.
|
||||
fn first_literal_argument(after_paren: &str) -> Option<String> {
|
||||
let trimmed = after_paren.trim_start();
|
||||
// An f-string's value depends on runtime state even though it is quoted.
|
||||
if trimmed.starts_with('f') {
|
||||
return None;
|
||||
}
|
||||
let bytes = trimmed.as_bytes();
|
||||
let quote = *bytes.first()?;
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
return None;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut index = 1usize;
|
||||
while index < bytes.len() {
|
||||
let byte = bytes[index];
|
||||
if byte == b'\\' {
|
||||
let next = *bytes.get(index + 1)?;
|
||||
value.push(next as char);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if byte == quote {
|
||||
// Reject `"a" + b` — the call site is only partly literal, so the
|
||||
// full argument is not knowable.
|
||||
let rest = trimmed[index + 1..].trim_start();
|
||||
if rest.starts_with('+') || rest.starts_with('%') {
|
||||
return None;
|
||||
}
|
||||
return Some(value);
|
||||
}
|
||||
value.push(byte as char);
|
||||
index += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max: usize) -> String {
|
||||
if value.chars().count() <= max {
|
||||
return value.to_string();
|
||||
}
|
||||
let kept: String = value.chars().take(max.saturating_sub(1)).collect();
|
||||
format!("{kept}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pure_inspection_block_needs_no_approval() {
|
||||
let intent = scan("total = len(content)\nprint(total)\nFINAL(total)");
|
||||
assert!(intent.is_empty(), "{intent:?}");
|
||||
assert!(intent.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_calls_are_reported_exactly() {
|
||||
let intent = scan("bash(\"cargo test\")\nedit(\"src/lib.rs\")");
|
||||
let details: Vec<_> = intent
|
||||
.intents
|
||||
.iter()
|
||||
.map(|i| (i.kind, i.detail.clone()))
|
||||
.collect();
|
||||
assert!(details.contains(&(IntentKind::Command, Some("cargo test".to_string()))));
|
||||
assert!(details.contains(&(IntentKind::FileWrite, Some("src/lib.rs".to_string()))));
|
||||
assert!(intent.is_complete(), "all arguments were literal");
|
||||
}
|
||||
|
||||
/// The case the whole module exists for: an argument the block computes
|
||||
/// cannot be described in advance, so it must be declared unknown rather
|
||||
/// than omitted. Omitting it would let a block that runs arbitrary
|
||||
/// commands present itself as doing nothing.
|
||||
#[test]
|
||||
fn computed_arguments_are_undecidable_not_omitted() {
|
||||
for code in [
|
||||
"bash(cmd)",
|
||||
"bash(f\"cargo test {name}\")",
|
||||
"bash(\"cargo \" + verb)",
|
||||
"bash(build_command())",
|
||||
] {
|
||||
let intent = scan(code);
|
||||
assert_eq!(intent.intents.len(), 1, "{code}: {intent:?}");
|
||||
assert_eq!(intent.intents[0].kind, IntentKind::Undecidable, "{code}");
|
||||
assert!(
|
||||
!intent.is_complete(),
|
||||
"{code} must force a runtime gate: {intent:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Over-reporting is its own failure: if the manifest lists operations the
|
||||
/// block never performs, people stop reading it.
|
||||
#[test]
|
||||
fn bindings_named_in_comments_and_strings_are_not_calls() {
|
||||
let intent = scan(
|
||||
"# bash(\"rm -rf /\") is what we are NOT doing\n\
|
||||
note = \"call bash(\\\"ls\\\") later\"\n\
|
||||
print(note)",
|
||||
);
|
||||
assert!(intent.is_empty(), "{intent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_and_longer_identifiers_are_not_our_bindings() {
|
||||
let intent = scan("subprocess.run(\"ls\")\nrerun(\"x\")\nmy_bash(\"y\")");
|
||||
assert!(intent.is_empty(), "{intent:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_is_bounded_and_deduplicated() {
|
||||
let code = (0..40)
|
||||
.map(|_| "bash(\"cargo test\")")
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let intent = scan(&code);
|
||||
assert_eq!(intent.intents.len(), 40);
|
||||
let lines = intent.summary_lines(5);
|
||||
assert!(lines.len() <= 5, "{lines:?}");
|
||||
assert!(lines[0].contains("cargo test"), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_and_network_bindings_are_classified() {
|
||||
let intent = scan("agent(\"review the diff\")\nfetch(\"https://example.com\")");
|
||||
let kinds: Vec<_> = intent.intents.iter().map(|i| i.kind).collect();
|
||||
assert!(kinds.contains(&IntentKind::Agent), "{intent:?}");
|
||||
assert!(kinds.contains(&IntentKind::Network), "{intent:?}");
|
||||
}
|
||||
}
|
||||
@@ -246,20 +246,33 @@ const DURABLE_WORK_OWNER: &str = "fleet_workflow_ledger";
|
||||
|
||||
/// Tool for writing and updating the todo list
|
||||
pub struct TodoWriteTool {
|
||||
name: &'static str,
|
||||
todo_list: SharedTodoList,
|
||||
}
|
||||
|
||||
impl TodoWriteTool {
|
||||
/// Canonical model-facing progress surface (#4132).
|
||||
pub fn work_update(todo_list: SharedTodoList) -> Self {
|
||||
Self { todo_list }
|
||||
Self {
|
||||
name: CANONICAL_PROGRESS_TOOL,
|
||||
todo_list,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden compat alias for `work_update` — same handler, not model-visible.
|
||||
pub fn alias(name: &'static str, todo_list: SharedTodoList) -> Self {
|
||||
Self { name, todo_list }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for TodoWriteTool {
|
||||
fn name(&self) -> &'static str {
|
||||
CANONICAL_PROGRESS_TOOL
|
||||
self.name
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
self.name == CANONICAL_PROGRESS_TOOL
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
|
||||
@@ -16,6 +16,7 @@ const MAX_LIFETIME_IMPRESSIONS: u8 = 2;
|
||||
const MAX_TRACKED_MANUAL_COMMANDS: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[allow(dead_code)]
|
||||
pub enum BehavioralTip {
|
||||
PlanningMode,
|
||||
BackgroundJobReceipt,
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"crates/tui/src/remote_control.rs",
|
||||
"crates/tui/src/repl/runtime.rs",
|
||||
"crates/tui/src/request_manifest.rs",
|
||||
"crates/tui/src/rlm/turn.rs",
|
||||
"crates/tui/src/route_billing.rs",
|
||||
"crates/tui/src/route_runtime.rs",
|
||||
"crates/tui/src/runtime_api.rs",
|
||||
@@ -190,7 +191,7 @@
|
||||
"_todo_2026_08_06_unreleased": "Post-0.9.4 [Unreleased] work: aggregate 673375 -> 676325 and max module 17596 -> 17631. The bulk is Agent Plugins v1.0.0 consume/publish/slugify (a new plugins/agent_plugin.rs plus manifest/registry/export wiring, ~2.3k lines with its tests). The rest is the sub-agent billing work \u2014 the agent_spawned stream event that makes a child's model visible, the RLM block-intent scanner, memory revise/retire with journals, and the harness audit trail. main.rs takes the module bump from the stream event. No new packages or binaries. The standing note still applies: this is a ledger, not a new normal \u2014 pay it down by deletion and dedup rather than treating growth as routine.",
|
||||
"document_kind": "codewhale.source_structure_budget",
|
||||
"large_module_threshold_lines": 1000,
|
||||
"max_large_module_count": 176,
|
||||
"max_large_module_count": 177,
|
||||
"max_module_lines": 17631,
|
||||
"max_total_owned_rust_lines": 676325,
|
||||
"schema_version": 1,
|
||||
@@ -215,5 +216,6 @@
|
||||
"codewhale-tui",
|
||||
"codewhale-workflow",
|
||||
"codewhale-workflow-js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"_todo_2026_08_07_rlm_turn": "v0.9.4 REPL fence + turn liveness: rlm/turn.rs 1081 lines becomes newly allowed (previously under threshold). Large-module count 176 -> 177. Provenance/empty-guard growth in turn.rs + honest exit ladder in turn_loop.rs. No new packages. Pay down in v0.9.5."
|
||||
}
|
||||
Generated
+3
-3
@@ -8466,9 +8466,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user