refactor(goal): the model decides goals; the host stops guessing from verb lists

Founder clarification, 2026-09-09 (docs/design/TUI_DECONSTRUCTION.md): "the
harness should let the model decide when a goal, plan, delegation, further
investigation, or verification is useful." Operate violated that with three
conflicting authorities: `operate_goal_from_prompt` classified instructions
with work-verb and chat-opener lists, `CreateGoalTool::description` said goals
require an explicit request, and the host promoted a prompt before the model
ever ran. The verb list produced exactly the absurdity it invites: "test
respond with hello" became a persistent goal because "test" is a verb.

- `operate_goal_from_prompt` and its vocabularies are deleted (~8.6 KB with
  tests). The engine creates a goal only from an explicit user declaration —
  `/goal` and the natural-language forms `explicit_goal_directive` parses — or
  when the model calls `create_goal`. No wording is classified.
- `create_goal`'s description becomes the one model-facing contract: the model
  decides when a request is a durable objective, and is told what is not one
  (a question, a greeting, a one-shot edit, a conversational probe).
- The create-refusal report is now always the explicit one: a `/goal` that
  `GoalState::create` refuses says so instead of being swallowed.
- docs/MODES.md (Operate) updated to match; the design doc's "conflicting
  authorities" note is resolved by this change.

Tests updated to the contract, not the old behavior:
- `operate_never_promotes_wording_to_a_goal`: ordinary work prompts are
  ordinary turns in Operate and Work; an explicit declaration still creates
  the goal.
- `operate_contract_is_appended_once_and_an_existing_goal_is_never_replaced`
  seeds its goal through the explicit declaration.
- `operate_model_shell_uses_normal_approval_and_workspace_sandbox` declares its
  goal explicitly instead of relying on promotion.

Evidence: goal filter "148 passed; 0 failed"; operate filter "55 passed;
0 failed"; clippy (CI's invocation, tui lib) clean.
This commit is contained in:
CodeWhale Bot committed 2026-09-17 14:37:05 -07:00
1 parent c191943cda
commit 4de9e9e281
4 files changed
+36 -286

No files matched your search

+8 -17
View File
@@ -41,7 +41,7 @@ use crate::route_runtime::{
};
use crate::tools::goal::{
GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, explicit_goal_directive,
new_shared_goal_state, operate_goal_from_prompt,
new_shared_goal_state,
};
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
@@ -4866,9 +4866,11 @@ impl Engine {
// runtime text, recalled memory, handoffs, and pasted multi-line
// transcripts cannot create a goal.
//
// Operate turns an ordinary work prompt into the goal through this
// same path when the host reports no unfinished goal
// (`operate_goal_from_prompt` owns the "is this real work?" rule).
// Goals are created by the model (`create_goal`) or by this literal
// user declaration; the host never infers one from wording. The
// verb-list promotion that used to turn ordinary Operate prompts into
// goals is gone (docs/design/TUI_DECONSTRUCTION.md — founder
// clarification 2026-09-09: the model decides when a goal is useful).
// `GoalState::create` still refuses while an unfinished goal exists,
// so a paused or blocked goal is never silently replaced.
//
@@ -4876,19 +4878,10 @@ impl Engine {
// <session_goal> contributor. It adds no new stable-prefix text.
let goal_request = if provenance.can_authorize_work() {
explicit_goal_directive(&content)
.map(|directive| (directive, true))
.or_else(|| {
let host_goal_unfinished =
goal_objective.is_some() && goal_status != GoalStatus::Complete;
(mode == AppMode::Operate && !host_goal_unfinished)
.then(|| operate_goal_from_prompt(&content))
.flatten()
.map(|directive| (directive, false))
})
} else {
None
};
if let Some((directive, explicit)) = goal_request {
if let Some(directive) = goal_request {
let result = self
.config
.goal_state
@@ -4910,7 +4903,7 @@ impl Engine {
// even when this model would otherwise reply only in prose.
let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await;
}
Err(error) if explicit => {
Err(error) => {
let _ = self
.tx_event
.send(Event::status(format!(
@@ -4918,8 +4911,6 @@ impl Engine {
)))
.await;
}
// Operate keeps the unfinished goal it already has.
Err(_) => {}
}
}
+26 -31
View File
@@ -3375,17 +3375,19 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
}
#[tokio::test]
async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() {
async fn operate_never_promotes_wording_to_a_goal() {
let prompt =
"Migrate the settings loader to the new config crate and keep the old keys readable";
// The verb-list promotion is gone: an ordinary work prompt is an ordinary
// turn in every mode, and the model decides goals through `create_goal`
// (docs/design/TUI_DECONSTRUCTION.md, founder clarification 2026-09-09).
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, prompt).await;
assert_eq!(
objective.as_deref(),
Some(prompt),
"Operate must publish the prompt as the goal before the provider call"
objective, None,
"the host must not infer a goal from wording"
);
assert!(active, "Operate goal must be active in engine state");
assert!(!active);
assert_eq!(contracts, 1, "Operate appends its contract exactly once");
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, prompt).await;
@@ -3393,11 +3395,12 @@ async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() {
assert!(!active);
assert_eq!(contracts, 0, "Work never sees the Operate contract");
// An explicit declaration still creates one, through the same path.
let (objective, active, contracts) =
operate_goal_probe(AppMode::Operate, "thanks, looks good").await;
assert_eq!(objective, None, "chat stays chat even in Operate");
assert!(!active);
assert_eq!(contracts, 1, "the contract is about the mode, not the goal");
operate_goal_probe(AppMode::Operate, "Please set /goal to ship the release").await;
assert_eq!(objective.as_deref(), Some("ship the release"));
assert!(active, "an explicit declaration must create the goal");
assert_eq!(contracts, 1);
}
#[tokio::test]
@@ -3502,10 +3505,11 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
})
};
let first =
let first_objective =
"Migrate the settings loader to the new config crate and keep the old keys readable";
let first = format!("Please set /goal to {first_objective}");
handle
.send(send(first, None, crate::tools::goal::GoalStatus::Active))
.send(send(&first, None, crate::tools::goal::GoalStatus::Active))
.await
.expect("send first Operate turn");
tokio::time::timeout(model_turn_event_timeout(), first_entered.notified())
@@ -3513,7 +3517,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
.expect("first provider request was never entered");
assert_eq!(
goal_state.lock().expect("goal lock").objective(),
Some(first)
Some(first_objective)
);
handle
.send(Op::SetGoalStatus {
@@ -3534,7 +3538,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
handle
.send(send(
"Refactor the provider table so it survives a config reload",
Some(first.to_string()),
Some(first_objective.to_string()),
crate::tools::goal::GoalStatus::Paused,
))
.await
@@ -3554,7 +3558,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
"the contract must not repeat on later Operate turns"
);
let goal = goal_state.lock().expect("goal lock").snapshot();
assert_eq!(goal.objective.as_deref(), Some(first));
assert_eq!(goal.objective.as_deref(), Some(first_objective));
assert_eq!(goal.status, "paused");
handle.send(Op::Shutdown).await.expect("shutdown engine");
@@ -12368,9 +12372,10 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
"\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
// Operate turns the work prompt into a goal, so after the approved shell
// runs, the model must seal the goal through the same `update_goal` tool
// a live Operate turn uses; only then does the final "done" arrive.
// Operate no longer infers a goal from wording, so this fixture declares
// one explicitly; after the approved shell runs, the model seals it
// through the same `update_goal` tool a live Operate turn uses, and only
// then does the final "done" arrive.
let goal_seal_marker = "goal-seal-receipt-0902";
let goal_sse = concat!(
"data: {\"id\":\"chatcmpl-operate-goal\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[",
@@ -12456,7 +12461,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
handle
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "write the requested local fixture".to_string(),
content: "Please set /goal to write the requested local fixture".to_string(),
images: Vec::new(),
mode: AppMode::Operate,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
@@ -12494,19 +12499,9 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
Event::ApprovalRequired { id, tool_name, .. } => {
saw_approval = true;
assert_eq!(tool_name, "Bash");
// Operate turned this work prompt into the goal; pause it
// before the turn ends so the goal-continuation loop cannot
// queue passes nobody answers in this mock. The goal-complete
// `update_goal` response below stays: it is the receipt a
// real Operate turn seals with.
handle_for_approval
.send(Op::SetGoalStatus {
goal_id: None,
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
.await
.expect("queue goal pause");
// No goal is created for this prompt: goals are model-decided
// (`create_goal`), so there is no goal-continuation loop to
// pause in this mock.
handle_for_approval
.approve_tool_call(id)
.await
+1 -237
View File
@@ -154,149 +154,6 @@ fn normalize_explicit_goal_objective(raw: &str) -> Option<String> {
(!objective.is_empty()).then(|| objective.to_string())
}
/// Operate's automatic goal: Operate turns an ordinary work prompt into the
/// session goal when no unfinished goal exists (docs/MODES.md, "Operate loop").
/// It feeds the same `GoalState::create` path as [`explicit_goal_directive`];
/// an explicit `/goal` declaration always wins, and Plan and Work never call
/// this.
///
/// Only a direct work instruction is considered for automatic persistence.
/// Prompt length alone never authorizes a goal: questions and conversational
/// followups and requests declining a goal remain ordinary turns. Ambiguous requests still run normally;
/// the user can use `/goal` to explicitly request persistent work.
#[must_use]
pub fn operate_goal_from_prompt(input: &str) -> Option<ExplicitGoalDirective> {
let input = input.trim();
if input.starts_with(['"', '\'', '`', '>']) {
return None;
}
// Mode defaults cannot override a user's request to avoid persistence.
// Keep this conservative: declining an automatic goal still runs the task.
let lower = input.to_lowercase().replace('’', "'");
if [
"no goal",
"without a goal",
"without goal",
"不要创建目标",
"不要设置目标",
]
.iter()
.any(|phrase| lower.contains(phrase))
|| lower
.split(['.', '!', '?', ';', '。', '!', '?', ';'])
.any(|clause| {
["do not ", "don't ", "never ", "without "]
.iter()
.filter_map(|negation| clause.find(negation))
.any(|start| {
// A negated list carries through commas: "do not edit
// files, create a goal, or start another tool".
let words: Vec<_> = clause[start..]
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.collect();
words.iter().any(|word| matches!(*word, "goal" | "goals"))
&& words.iter().any(|word| {
matches!(
*word,
"create"
| "creating"
| "set"
| "setting"
| "start"
| "starting"
| "track"
| "tracking"
| "use"
| "using"
)
})
})
})
{
return None;
}
let words: Vec<&str> = input.split_whitespace().collect();
if words.len() < 3 {
return None;
}
let normalize = |word: &str| {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_ascii_lowercase()
};
let head = words
.iter()
.map(|word| normalize(word))
.find(|word| word != "please")
.unwrap_or_default();
if OPERATE_CHAT_OPENERS.contains(&head.as_str()) {
return None;
}
let first_line = input.lines().find(|line| !line.trim().is_empty())?.trim();
if first_line.ends_with(['?', '?']) || input.ends_with(['?', '?']) {
return None;
}
if !OPERATE_WORK_VERBS.contains(&head.as_str()) {
return None;
}
let mut objective = words.join(" ");
if objective.chars().count() > OPERATE_GOAL_MAX_OBJECTIVE_CHARS {
objective = objective
.chars()
.take(OPERATE_GOAL_MAX_OBJECTIVE_CHARS)
.collect::<String>()
.trim_end()
.to_string();
objective.push('…');
}
Some(ExplicitGoalDirective { objective })
}
const OPERATE_GOAL_MAX_OBJECTIVE_CHARS: usize = 600;
const OPERATE_CHAT_OPENERS: &[&str] = &[
"hi", "hello", "hey", "thanks", "thank", "ok", "okay", "yes", "no", "sure", "great", "cool",
"nice", "lol",
];
const OPERATE_WORK_VERBS: &[&str] = &[
"add",
"build",
"change",
"clean",
"convert",
"create",
"debug",
"deploy",
"design",
"document",
"extract",
"finish",
"fix",
"implement",
"improve",
"integrate",
"investigate",
"make",
"migrate",
"move",
"optimize",
"port",
"refactor",
"remove",
"rename",
"replace",
"resolve",
"rewrite",
"run",
"ship",
"split",
"test",
"update",
"upgrade",
"verify",
"wire",
"write",
];
/// Runtime status for a goal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum GoalStatus {
@@ -1129,7 +986,7 @@ impl ToolSpec for CreateGoalTool {
}
fn description(&self) -> &'static str {
"Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. Call this only when the user explicitly asks to use `/goal`, make an objective the goal, or otherwise explicitly requests persistent goal tracking. When the request is explicit, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Never infer a goal from an ordinary task, its apparent length, a question, or a one-file edit. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another."
"Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. You decide when a request is a durable objective worth carrying across turns — a multi-step outcome the user will want continued and verified. Do not create a goal for a question, a greeting, a one-shot edit, or a conversational probe; those are ordinary turns. When the user explicitly asks to use `/goal` or asks you to make something the goal, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another."
}
fn input_schema(&self) -> Value {
@@ -1478,99 +1335,6 @@ mod tests {
}
}
#[test]
fn operate_goal_from_prompt_promotes_work_and_leaves_chat_alone() {
let goal = operate_goal_from_prompt(
"Migrate the settings loader to the new config crate\nand keep the old keys readable.",
)
.expect("multi-line work prompt");
assert_eq!(
goal.objective,
"Migrate the settings loader to the new config crate and keep the old keys readable."
);
assert_eq!(
operate_goal_from_prompt("Please fix the flaky CI test")
.expect("imperative after please")
.objective,
"Please fix the flaky CI test"
);
for chat in [
"hi",
"thanks, looks good",
"ok go ahead",
"hello there, how are you doing today my friend",
"what does /goal do?",
"why is the build slow?",
"the tests",
] {
assert_eq!(
operate_goal_from_prompt(chat),
None,
"must stay chat: {chat}"
);
}
let long = "fix ".repeat(400);
let objective = operate_goal_from_prompt(&long).expect("bounded").objective;
assert!(objective.chars().count() <= OPERATE_GOAL_MAX_OBJECTIVE_CHARS + 1);
assert!(objective.ends_with('…'));
}
#[test]
fn operate_does_not_promote_conversation_by_length_or_quoted_commands() {
for prompt in [
"what about like rust or docker builds or something",
"what about like rust or docker builds or something?",
"why did the build fail on the last step when running docker on macos",
"Could you look at why the provider table drops rows after reload and repair it?",
"the Rust and Docker builds might explain the disk usage we saw",
"explain how the goal loop decides whether to continue working",
"那 rust 或者 docker 构建呢",
"请问这个模块的具体实现原理是什么以及它如何与其他服务交互",
"修复这个问题需要什么步骤",
"Build the release now?",
"Build the release now?",
"\"build the release now\"",
"`build the release now`",
"> build the release now",
"in the log it says build failed, what should we do",
] {
assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}");
}
for prompt in [
"Build the release and verify the checksums",
"Please fix the flaky CI test",
"Fix 中文文档中的链接 and verify them",
"run \"cargo build --release\" and verify the result",
] {
assert_eq!(
operate_goal_from_prompt(prompt).expect(prompt).objective,
prompt
);
}
}
#[test]
fn operate_respects_goal_opt_out_including_negated_lists() {
for prompt in [
"Run one bounded cancellation check. Do not edit files, inspect other files, create a goal, spawn agents, or start any other tool.",
"Build the example, but don't create a goal.",
"Fix the button. Don’t create a persistent goal for this.",
"Run the check without a goal.",
"Verify the output; no goal tracking for this task.",
"Update the sample. Never set a goal automatically.",
"Fix the test. Do not use /goal.",
"Run the check without creating a goal.",
"Fix the example. Don't create any goals.",
"Run one check. Do not edit files,\ncreate a goal, or spawn agents.",
"Fix this issue, 不要创建目标。",
] {
assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}");
}
// A separate prohibition must not negate a later work instruction.
let work = "Fix the failing checks. Do not publish. Build the release and verify it.";
assert_eq!(operate_goal_from_prompt(work).unwrap().objective, work);
}
#[tokio::test]
async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() {
// #5123-class: `objective` used to return a success receipt with no
+1 -1
View File
@@ -34,7 +34,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`,
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available.
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. Goals still require an explicit user request; approval, sandbox, repository law, and managed policy decide what may execute.
- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. When no unfinished goal exists, a direct work instruction (not a greeting, acknowledgement, question, quoted command, conversational followup, or request declining goal tracking) becomes the session goal automatically, with continuation on; the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration still wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix.
- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. Goals are model-decided: the agent calls `create_goal` when a request is a durable objective, and `/goal` always works as the direct user control — the host never infers a goal from wording. Once a goal exists, the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration always wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix.
`Act` and `/mode act` remain compatibility aliases for Work. Saved settings
still normalize to the internal value `agent`.