diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index d243677..9aa1060 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -967,6 +967,7 @@ async def _get_or_create_client( self, session_id: str, source: str, model: str | None, fork_from: str | None = None, fork_last_turn_id: str | None = None, + effort_override: str | None = None, ) -> AgentClient: """Get an existing persistent client or create a new one. @@ -1165,7 +1166,7 @@ async def _record_wakeup_cb(sid: str, tool_input: dict) -> Any: session_id=session_id, source=source, model=requested_model, - effort=self._base_effort_for_source( + effort=effort_override or self._base_effort_for_source( source, self.config.agent.effort, self.config.agent.cron_effort, ), @@ -2103,6 +2104,7 @@ async def run( source: str = "web", channel: str | None = None, model: str | None = None, + effort_override: str | None = None, internal: bool = False, images: list[dict[str, Any]] | None = None, image_refs: list[dict[str, Any]] | None = None, @@ -2148,6 +2150,7 @@ async def run( try: return await self._run_inner( session_id, user_message, source, channel, model, + effort_override=effort_override, internal=internal, images=images, image_refs=image_refs, ) @@ -2191,6 +2194,7 @@ async def _run_inner( source: str, channel: str | None, model: str | None, + effort_override: str | None = None, internal: bool = False, images: list[dict[str, Any]] | None = None, image_refs: list[dict[str, Any]] | None = None, @@ -2271,6 +2275,7 @@ async def _run_inner( client = await self._get_or_create_client( session_id, source, model, fork_from=fork_from, fork_last_turn_id=fork_last_turn_id, + effort_override=effort_override, ) # Check for deferred /stop that arrived while we were setting up @@ -2380,6 +2385,7 @@ async def _run_inner( await self._safe_disconnect(client) client = await self._get_or_create_client( session_id, source, model, + effort_override=effort_override, ) continue # retry the query @@ -2416,6 +2422,7 @@ async def _run_inner( await self._safe_disconnect(client) client = await self._get_or_create_client( session_id, source, model, + effort_override=effort_override, ) continue # retry query + response break # success — exit retry loop @@ -2994,6 +3001,7 @@ async def run_cron( model: str | None = None, run_id: str | None = None, cache_ttl: str = "", + effort: str | None = None, ) -> str: """Run an agent turn for a cron job in an isolated session. @@ -3014,6 +3022,7 @@ async def run_cron( user_message=prompt, source="cron", model=model, # backend default_model(source) fills cron defaults + effort_override=effort, ) finally: await self._teardown_oneshot_client(session_id) @@ -3025,6 +3034,7 @@ async def run_persistent_cron( model: str | None = None, session_id: str | None = None, cache_ttl: str = "", + effort: str | None = None, ) -> str: """Run a persistent cron job that maintains context across runs. @@ -3052,6 +3062,7 @@ async def run_persistent_cron( user_message=prompt, source="cron", model=model, # backend default_model(source) fills cron defaults + effort_override=effort, ) finally: # The session is reused by the next run (until rotation), which diff --git a/nerve/cron/jobs.py b/nerve/cron/jobs.py index 60567e6..a612995 100644 --- a/nerve/cron/jobs.py +++ b/nerve/cron/jobs.py @@ -33,6 +33,7 @@ class CronJob: prompt_file: str = "" description: str = "" model: str = "" # Override model; empty = use config default + effort: str = "" # Override reasoning effort (low/medium/high/xhigh/max); empty = source default (cron_effort) # Per-job prompt-cache TTL override: "5m", "1h" or "auto"; empty = use # agent.cache_ttl from config. See nerve/agent/cache_policy.py. cache_ttl: str = "" @@ -121,6 +122,7 @@ def from_dict(cls, d: dict, base_dir: Path | None = None) -> CronJob: prompt_file=d.get("prompt_file", ""), description=d.get("description", ""), model=d.get("model", ""), + effort=d.get("effort", ""), cache_ttl=d.get("cache_ttl", ""), session_mode=d.get("session_mode", "isolated"), context_rotate_hours=int(d.get("context_rotate_hours", 24)), @@ -183,6 +185,7 @@ def save_jobs(jobs: list[CronJob], jobs_file: Path) -> None: "prompt_file": job.prompt_file, "description": job.description, "model": job.model, + "effort": job.effort, "cache_ttl": job.cache_ttl, "session_mode": job.session_mode, "context_rotate_hours": job.context_rotate_hours, diff --git a/nerve/cron/service.py b/nerve/cron/service.py index c3b0bae..d71153e 100644 --- a/nerve/cron/service.py +++ b/nerve/cron/service.py @@ -609,6 +609,7 @@ async def _run_job_inner(self, job: CronJob) -> None: try: model = job.model or self.config.agent.cron_model + effort = job.effort or None # per-job effort override; None = source default (cron_effort) rotated = False base_prompt = job.resolve_prompt() @@ -665,6 +666,7 @@ async def _run_job_inner(self, job: CronJob) -> None: model=model, session_id=session_id, cache_ttl=job.cache_ttl, + effort=effort, ) else: response = await self.engine.run_cron( @@ -673,6 +675,7 @@ async def _run_job_inner(self, job: CronJob) -> None: model=model, run_id=run_id, cache_ttl=job.cache_ttl, + effort=effort, ) # Keep the tail of the response — for multi-message runs the diff --git a/tests/test_cron.py b/tests/test_cron.py index 6d6f4ed..d1614bd 100644 --- a/tests/test_cron.py +++ b/tests/test_cron.py @@ -525,6 +525,36 @@ def test_from_dict_explicit_true(self): assert job.lock is True +# --------------------------------------------------------------------------- +# CronJob.effort field (per-job reasoning-effort override) +# --------------------------------------------------------------------------- + +class TestCronJobEffort: + def test_default_empty(self): + job = _make_job() + assert job.effort == "" + + def test_from_dict_default(self): + job = CronJob.from_dict({"id": "x", "schedule": "1h", "prompt": "p"}) + assert job.effort == "" + + def test_from_dict_explicit(self): + job = CronJob.from_dict({ + "id": "x", "schedule": "1h", "prompt": "p", "effort": "high", + }) + assert job.effort == "high" + + def test_round_trips_through_save_load(self, tmp_path): + from nerve.cron.jobs import load_jobs, save_jobs + + job = CronJob.from_dict({ + "id": "x", "schedule": "1h", "prompt": "p", "effort": "xhigh", + }) + out = tmp_path / "out.yaml" + save_jobs([job], out) + assert load_jobs(out)[0].effort == "xhigh" + + # --------------------------------------------------------------------------- # Job lock (concurrent run serialization) # ---------------------------------------------------------------------------