Synced from monorepo
Synced from monorepo Changes: - Workspace task snapshots only list incomplete backgrounded tasks - Quiet auth, LSP, and config warnings in the shell - Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials - Fail closed when soak metrics are missing - Run plan-mode exit last in mixed tool batches - Allow /loop to store prompts that can terminate the loop - Make subagent maximum nesting depth configurable - Security: apply sandbox profile to the leader process that executes tools Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
parent
b41c75a578
commit
02d9359435
96 changed files with 2346 additions and 351 deletions
|
|
@ -15,37 +15,81 @@ pub fn loop_usage_message() -> &'static str {
|
|||
Tell me how often it should run (e.g. 30m, 1 hour, every 2 days)."
|
||||
}
|
||||
|
||||
/// Where a scheduled fire runs, which decides what the stored prompt can rely on.
|
||||
///
|
||||
/// Resolved from `[scheduler] background_loops` (env, config, managed policy and
|
||||
/// remote settings all feed it), so `/loop` describes the runtime the user
|
||||
/// actually has rather than hedging across both.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoopFireMode {
|
||||
/// Each fire runs in a detached background subagent that cannot see this
|
||||
/// conversation. The default.
|
||||
Detached,
|
||||
/// Each fire runs as a turn in this conversation, where earlier results from
|
||||
/// the same task may still be visible.
|
||||
InSession,
|
||||
}
|
||||
|
||||
/// Build the model instruction that `/loop` expands into for `args`.
|
||||
///
|
||||
/// The model, not brittle host parsing, turns the request into the
|
||||
/// `scheduler_create` interval, accepting every natural phrasing and erroring
|
||||
/// on bad input rather than silently defaulting. See [`loop_usage_message`].
|
||||
pub fn loop_schedule_instruction(args: &str) -> String {
|
||||
///
|
||||
/// Only the framing differs by `mode`; the stop condition and length guidance
|
||||
/// are identical, because both hold wherever the fire runs.
|
||||
pub fn loop_schedule_instruction(args: &str, mode: LoopFireMode) -> String {
|
||||
let fire_context = match mode {
|
||||
LoopFireMode::Detached => {
|
||||
"Each fire runs in a detached background subagent, not in this conversation,\n\
|
||||
so the prompt you store must stand on its own.\n\n\
|
||||
## Writing a prompt that survives a fresh fire\n\
|
||||
- Inline the state a fire needs: paths, job/PR/branch ids, the command that checks\n\
|
||||
status, and what \"healthy\" looks like. A fire cannot see this conversation, and\n\
|
||||
a long-running task restarts from a short summary every few iterations.\n\
|
||||
- Only a short status comes back here, so say what that status must contain."
|
||||
}
|
||||
LoopFireMode::InSession => {
|
||||
"Each fire arrives as a new turn in this conversation, and earlier results from\n\
|
||||
the same task may still be above it. The stored prompt is re-sent verbatim every\n\
|
||||
time, so write a standing order rather than a one-off request.\n\n\
|
||||
## Writing a prompt that reads well on every fire\n\
|
||||
- Name the state that must not be guessed: paths, job/PR/branch ids, the command\n\
|
||||
that checks status, and what \"healthy\" looks like. This conversation is\n\
|
||||
compacted as it grows, so do not rely on details staying visible.\n\
|
||||
- Earlier fires may be above you: continue from them instead of restarting."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"# /loop -- schedule a recurring prompt\n\n\
|
||||
Parse the input below into an interval and a prompt, then schedule it with scheduler_create.\n\n\
|
||||
Turn the input below into a scheduler_create call. {fire_context}\n\
|
||||
- Say what one fire does and when it bails: \"if still pending, report one line and\n\
|
||||
stop.\" A fire must not poll inline.\n\
|
||||
- Give it a stop condition and an exit: \"when <condition> holds, report it and call\n\
|
||||
scheduler_delete <task_id>.\" Without that the loop runs until it expires.\n\
|
||||
- Keep it short and concrete -- the stored prompt is re-sent on every fire.\n\n\
|
||||
## Deriving the interval\n\
|
||||
Read how often to run from the user's request — however they phrase it — and convert it\n\
|
||||
to a compact `<number><unit>` string, where unit is one of `s` (seconds), `m` (minutes),\n\
|
||||
`h` (hours), or `d` (days). The interval may appear at the start or end of the request;\n\
|
||||
extract it and use the remaining text as the prompt.\n\n\
|
||||
The minimum interval is 60 seconds; shorter values are raised to 60s, so tell the user if that applies.\n\n\
|
||||
If the request contains no interval at all, ask the user how often it should run before\n\
|
||||
scheduling. Do NOT invent or assume a default interval.\n\n\
|
||||
Convert the user's cadence -- however phrased, at either end of the request -- into a\n\
|
||||
compact `<number><unit>` string (`s`/`m`/`h`/`d`); the remaining text is the prompt.\n\
|
||||
The minimum is 60 seconds and shorter values are raised, so say so when it applies.\n\
|
||||
If no cadence is given, ask the user how often it should run -- never invent one.\n\n\
|
||||
## Action\n\
|
||||
1. Call scheduler_create with: interval (the compact string you derived), prompt,\n\
|
||||
fire_immediately: true. If the interval is unparseable, the tool\n\
|
||||
returns an error — fix the interval string rather than guessing.\n\
|
||||
2. Confirm: what's scheduled, the cadence, that it auto-expires after 7 days,\n\
|
||||
and that they can cancel with scheduler_delete (include the job ID).\n\
|
||||
3. Do NOT execute the prompt inline. The scheduler will fire it immediately.\n\n\
|
||||
Schedule from what the user already gave you \u{2014} do not explore the workspace or run\n\
|
||||
checks before scheduling; the first fire does that.\n\
|
||||
1. Call scheduler_create with the interval, the prompt, and fire_immediately: true.\n\
|
||||
If the interval is rejected, fix the string rather than guessing.\n\
|
||||
2. Confirm what's scheduled, the cadence, its stop condition, that it auto-expires\n\
|
||||
after 7 days, and the task_id to cancel with scheduler_delete.\n\
|
||||
3. Do NOT execute the prompt inline. The scheduler fires it immediately.\n\n\
|
||||
## Wrong tool for the job\n\
|
||||
- \"Tell me when X finishes\" -> a background command or watch tool that wakes you on\n\
|
||||
the event, not a recurring loop that re-checks on a timer.\n\
|
||||
- \"Do X once in N minutes\" -> background `sleep <secs> && <command>`; scheduling is\n\
|
||||
recurring-only.\n\n\
|
||||
## Changing an existing loop\n\
|
||||
Call scheduler_create with its task_id and the fields that change; do not\n\
|
||||
Call scheduler_create with its task_id and only the changed fields; do not\n\
|
||||
delete and recreate. If later work changes what a loop should do, update its\n\
|
||||
prompt the same way.\n\n\
|
||||
## One-time delayed work\n\
|
||||
Scheduling is recurring-only. For \"do X once in N minutes\", run a background\n\
|
||||
terminal command (`sleep <secs> && <command>`); its completion notifies you.\n\n\
|
||||
## Input\n\
|
||||
{args}"
|
||||
)
|
||||
|
|
@ -197,23 +241,50 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn instruction_carries_args_and_contract_tokens() {
|
||||
let text = loop_schedule_instruction("every 30 minutes do x");
|
||||
assert!(text.contains("every 30 minutes do x"));
|
||||
assert!(text.contains("<number><unit>"));
|
||||
assert!(text.contains("ask the user how often"));
|
||||
assert!(!text.contains("10m"), "no host-side default interval");
|
||||
assert!(
|
||||
!text.contains("recurring:"),
|
||||
"the retired one-shot flag must not be referenced"
|
||||
);
|
||||
assert!(
|
||||
text.contains("task_id"),
|
||||
"must teach in-place updates via task_id"
|
||||
);
|
||||
assert!(
|
||||
text.contains("delete and recreate"),
|
||||
"must steer away from delete+recreate"
|
||||
);
|
||||
for mode in [LoopFireMode::Detached, LoopFireMode::InSession] {
|
||||
let text = loop_schedule_instruction("every 30 minutes do x", mode);
|
||||
assert!(text.contains("every 30 minutes do x"), "{mode:?}");
|
||||
assert!(text.contains("<number><unit>"), "{mode:?}");
|
||||
assert!(text.contains("ask the user how often"), "{mode:?}");
|
||||
assert!(
|
||||
!text.contains("10m"),
|
||||
"no host-side default interval: {mode:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("recurring:"),
|
||||
"the retired one-shot flag must not be referenced: {mode:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("task_id"),
|
||||
"must teach in-place updates via task_id: {mode:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("delete and recreate"),
|
||||
"must steer away from delete+recreate: {mode:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("scheduler_delete <task_id>"),
|
||||
"every mode must authorize the fire to end the task: {mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_fire_mode_describes_its_own_runtime() {
|
||||
let detached = loop_schedule_instruction("5m check ci", LoopFireMode::Detached);
|
||||
let in_session = loop_schedule_instruction("5m check ci", LoopFireMode::InSession);
|
||||
|
||||
assert!(detached.contains("cannot see this conversation"));
|
||||
assert!(!detached.contains("arrives as a new turn in this conversation"));
|
||||
|
||||
assert!(in_session.contains("arrives as a new turn in this conversation"));
|
||||
assert!(!in_session.contains("cannot see this conversation"));
|
||||
|
||||
// The two levers the A/B showed carry the behavior are mode-independent.
|
||||
for text in [&detached, &in_session] {
|
||||
assert!(text.contains("report it and call"));
|
||||
assert!(text.contains("Keep it short and concrete"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue