feat: add cron job profile support

This commit is contained in:
Gianfranco Piana 2026-05-14 16:42:39 -03:00 committed by daimon-nous[bot]
parent 47bc8e080d
commit bb9ecb2178
7 changed files with 489 additions and 10 deletions

View file

@ -128,6 +128,9 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
state = "scheduled" if normalized.get("enabled", True) else "paused"
normalized["state"] = state
profile = _coerce_job_text(normalized.get("profile")).strip()
normalized["profile"] = profile or None
return normalized
@ -479,6 +482,30 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
return str(resolved)
def _normalize_profile(profile: Optional[str]) -> Optional[str]:
"""Normalize and validate an optional cron job profile name.
Empty / None disables per-job profile selection. Otherwise the profile name
is canonicalized with the same rules as ``hermes -p`` and must refer to an
existing profile at create/update time. ``default`` is the built-in root
profile and is always valid.
"""
if profile is None:
return None
raw = str(profile).strip()
if not raw:
return None
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
normalized = normalize_profile_name(raw)
# resolve_profile_env validates the canonical name and checks that named
# profiles exist. Store only the stable profile id, not the filesystem path,
# so profile directories can move with the Hermes root.
resolve_profile_env(normalized)
return normalized
def create_job(
prompt: Optional[str],
schedule: str,
@ -495,6 +522,7 @@ def create_job(
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
profile: Optional[str] = None,
no_agent: bool = False,
) -> Dict[str, Any]:
"""
@ -536,6 +564,11 @@ def create_job(
With ``no_agent=True``, ``workdir`` is still applied as the
script's cwd so relative paths inside the script behave
predictably.
profile: Optional Hermes profile name. When set, the job runs with
that profile's HERMES_HOME so profile-specific config,
credentials, scripts, skills, and memory paths resolve
consistently. ``default`` selects the root profile; empty /
None preserves the scheduler's existing behaviour.
no_agent: When True, skip the agent entirely run ``script`` on schedule
and deliver its stdout directly. Empty stdout = silent (no
delivery). Requires ``script`` to be set. Ideal for classic
@ -573,6 +606,7 @@ def create_job(
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
normalized_toolsets = normalized_toolsets or None
normalized_workdir = _normalize_workdir(workdir)
normalized_profile = _normalize_profile(profile)
normalized_no_agent = bool(no_agent)
# no_agent jobs are meaningless without a script — the script IS the job.
@ -627,6 +661,7 @@ def create_job(
"origin": origin, # Tracks where job was created for "origin" delivery
"enabled_toolsets": normalized_toolsets,
"workdir": normalized_workdir,
"profile": normalized_profile,
}
jobs = load_jobs()
@ -707,6 +742,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
else:
updates["workdir"] = _normalize_workdir(_wd)
# Validate / normalize profile if present in updates. Empty string or
# None both mean "clear the field" (restore old behaviour).
if "profile" in updates:
_profile = updates["profile"]
if _profile is None or _profile == "" or _profile is False:
updates["profile"] = None
else:
updates["profile"] = _normalize_profile(_profile)
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates

View file

@ -17,6 +17,7 @@ import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@ -145,6 +146,49 @@ def _get_lock_paths() -> tuple[Path, Path]:
return lock_dir, lock_dir / ".tick.lock"
@contextmanager
def _job_profile_context(job_id: str, profile: Optional[str]):
"""Temporarily run a job under a specific Hermes profile.
Cron jobs are stored and scheduled by the profile running the scheduler, but
an individual job can opt into a different runtime profile. While active,
HERMES_HOME and the scheduler's test/override hook both point at the
resolved profile directory so _get_hermes_home(), .env/config loading, script
resolution, AIAgent construction, and downstream get_hermes_home() callers
agree on the same home.
"""
raw_profile = str(profile or "").strip()
if not raw_profile:
yield None
return
global _hermes_home
prior_env = os.environ.get("HERMES_HOME", "_UNSET_")
prior_override = _hermes_home
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
normalized_profile = normalize_profile_name(raw_profile)
profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
try:
os.environ["HERMES_HOME"] = str(profile_home)
_hermes_home = profile_home
logger.info(
"Job '%s': using Hermes profile '%s' (%s)",
job_id,
normalized_profile,
profile_home,
)
yield normalized_profile
finally:
_hermes_home = prior_override
if prior_env == "_UNSET_":
os.environ.pop("HERMES_HOME", None)
else:
os.environ["HERMES_HOME"] = prior_env
def _resolve_origin(job: dict) -> Optional[dict]:
"""Extract origin info from a job, preserving any extra routing metadata.
@ -1022,6 +1066,13 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str:
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""Execute a single cron job, applying any per-job profile override."""
job_id = job["id"]
with _job_profile_context(job_id, job.get("profile")):
return _run_job_impl(job)
def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
Execute a single cron job.
@ -1258,8 +1309,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# .cursorrules from the job's project dir, AND
# - the terminal, file, and code-exec tools run commands from there.
#
# tick() serializes workdir-jobs outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# tick() serializes jobs that mutate process-global runtime state (workdir
# and/or profile jobs) outside the parallel pool, so mutating
# os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
# (skip_context_files=True, tools use whatever cwd the scheduler has).
_job_workdir = (job.get("workdir") or "").strip() or None
@ -1781,17 +1833,24 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
mark_job_run(job["id"], False, str(e))
return False
# Partition due jobs: those with a per-job workdir mutate
# os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
# so they MUST run sequentially to avoid corrupting each other. Jobs
# without a workdir leave env untouched and stay parallel-safe.
workdir_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
# Partition due jobs: jobs with a per-job workdir and/or profile mutate
# process-global runtime state inside run_job (TERMINAL_CWD,
# HERMES_HOME, and the scheduler's _hermes_home hook), so they MUST run
# sequentially to avoid corrupting each other. Jobs without either field
# leave those env overrides untouched and stay parallel-safe.
sequential_jobs = [
j for j in due_jobs
if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
]
parallel_jobs = [
j for j in due_jobs
if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
]
_results: list = []
# Sequential pass for workdir jobs.
for job in workdir_jobs:
# Sequential pass for env-mutating jobs.
for job in sequential_jobs:
_ctx = contextvars.copy_context()
_results.append(_ctx.run(_process_job, job))

View file

@ -98,6 +98,9 @@ def cron_list(show_all: bool = False):
workdir = job.get("workdir")
if workdir:
print(f" Workdir: {workdir}")
profile = job.get("profile")
if profile:
print(f" Profile: {profile}")
# Execution history
last_status = job.get("last_status")
@ -174,6 +177,7 @@ def cron_create(args):
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
profile=getattr(args, "profile", None),
no_agent=getattr(args, "no_agent", False) or None,
)
if not result.get("success"):
@ -191,6 +195,8 @@ def cron_create(args):
print(" Mode: no-agent (script stdout delivered directly)")
if job_data.get("workdir"):
print(f" Workdir: {job_data['workdir']}")
if job_data.get("profile"):
print(f" Profile: {job_data['profile']}")
print(f" Next run: {result['next_run_at']}")
return 0
@ -236,6 +242,7 @@ def cron_edit(args):
skills=final_skills,
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
profile=getattr(args, "profile", None),
no_agent=getattr(args, "no_agent", None),
)
if not result.get("success"):
@ -256,6 +263,8 @@ def cron_edit(args):
print(" Mode: no-agent (script stdout delivered directly)")
if updated.get("workdir"):
print(f" Workdir: {updated['workdir']}")
if updated.get("profile"):
print(f" Profile: {updated['profile']}")
return 0

View file

@ -10624,6 +10624,10 @@ def main():
"--workdir",
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
)
cron_create.add_argument(
"--profile",
help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.",
)
# cron edit
cron_edit = cron_subparsers.add_parser(
@ -10688,6 +10692,10 @@ def main():
"--workdir",
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
)
cron_edit.add_argument(
"--profile",
help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.",
)
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")

View file

@ -0,0 +1,340 @@
"""Tests for per-job profile support in cron jobs.
Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime
HERMES_HOME scoping, and tick() serialization for profile jobs.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
@pytest.fixture()
def isolated_cron_profile_home(tmp_path, monkeypatch):
"""Create an isolated Hermes root with a named profile and temp cron store."""
root = tmp_path / "hermes-root"
profile_home = root / "profiles" / "support"
profile_home.mkdir(parents=True)
(root / "cron").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output")
return root, profile_home
class TestNormalizeProfile:
def test_none_and_empty_return_none(self, isolated_cron_profile_home):
from cron.jobs import _normalize_profile
assert _normalize_profile(None) is None
assert _normalize_profile("") is None
assert _normalize_profile(" ") is None
def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home):
from cron.jobs import _normalize_profile
assert _normalize_profile("Default") == "default"
def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home):
from cron.jobs import _normalize_profile
assert _normalize_profile("Support") == "support"
def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home):
from cron.jobs import _normalize_profile
with pytest.raises(ValueError):
_normalize_profile("invalid!")
def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home):
from cron.jobs import _normalize_profile
with pytest.raises(FileNotFoundError):
_normalize_profile("missing")
class TestCreateAndUpdateJobProfile:
def test_create_stores_profile_id(self, isolated_cron_profile_home):
from cron.jobs import create_job, get_job
job = create_job(prompt="hello", schedule="every 1h", profile="Support")
stored = get_job(job["id"])
assert stored is not None
assert stored["profile"] == "support"
def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home):
from cron.jobs import create_job, get_job
job = create_job(prompt="hello", schedule="every 1h")
stored = get_job(job["id"])
assert stored is not None
assert stored.get("profile") is None
def test_create_accepts_explicit_default(self, isolated_cron_profile_home):
from cron.jobs import create_job, get_job
job = create_job(prompt="hello", schedule="every 1h", profile="default")
stored = get_job(job["id"])
assert stored is not None
assert stored["profile"] == "default"
def test_update_sets_and_clears_profile(self, isolated_cron_profile_home):
from cron.jobs import create_job, get_job, update_job
job = create_job(prompt="x", schedule="every 1h")
update_job(job["id"], {"profile": "Support"})
stored = get_job(job["id"])
assert stored is not None
assert stored["profile"] == "support"
update_job(job["id"], {"profile": ""})
stored = get_job(job["id"])
assert stored is not None
assert stored["profile"] is None
def test_update_rejects_missing_profile(self, isolated_cron_profile_home):
from cron.jobs import create_job, update_job
job = create_job(prompt="x", schedule="every 1h")
with pytest.raises(FileNotFoundError):
update_job(job["id"], {"profile": "missing"})
class TestCronjobToolProfile:
def test_create_and_list_with_profile(self, isolated_cron_profile_home):
from tools.cronjob_tools import cronjob
created = json.loads(
cronjob(
action="create",
prompt="hi",
schedule="every 1h",
profile="Support",
)
)
assert created["success"] is True
assert created["job"]["profile"] == "support"
listing = json.loads(cronjob(action="list"))
assert listing["jobs"][0]["profile"] == "support"
def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home):
from tools.cronjob_tools import cronjob
created = json.loads(
cronjob(
action="create",
prompt="hi",
schedule="every 1h",
profile="Support",
)
)
updated = json.loads(
cronjob(action="update", job_id=created["job_id"], profile="")
)
assert updated["success"] is True
assert "profile" not in updated["job"]
def test_schema_advertises_profile(self):
from tools.cronjob_tools import CRONJOB_SCHEMA
assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"]
desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"]
assert "hermes profile" in desc.lower()
class TestRunJobProfileContext:
@staticmethod
def _install_agent_stubs(monkeypatch, observed: dict):
import sys
import cron.scheduler as sched
class FakeAgent:
def __init__(self, **kwargs):
observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME")
observed["scheduler_home_during_init"] = str(sched._get_hermes_home())
observed["skip_context_files"] = kwargs.get("skip_context_files")
def run_conversation(self, *_a, **_kw):
observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME")
observed["scheduler_home_during_run"] = str(sched._get_hermes_home())
return {"final_response": "done", "messages": []}
def get_activity_summary(self):
return {"seconds_since_activity": 0.0}
def close(self):
observed["closed"] = True
fake_mod = type(sys)("run_agent")
fake_mod.AIAgent = FakeAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)
from hermes_cli import runtime_provider as runtime_provider
monkeypatch.setattr(
runtime_provider,
"resolve_runtime_provider",
lambda **_kw: {
"provider": "test",
"api_key": "test-key",
"base_url": "http://test.local",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi")
monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None)
monkeypatch.setattr(sched, "_hermes_home", None)
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
import dotenv
def fake_load_dotenv(path, *_a, **_kw):
observed.setdefault("dotenv_paths", []).append(str(path))
return True
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
def test_run_job_sets_and_restores_profile_home(
self, isolated_cron_profile_home, monkeypatch
):
import cron.scheduler as sched
root, profile_home = isolated_cron_profile_home
observed: dict = {}
self._install_agent_stubs(monkeypatch, observed)
job = {
"id": "abc",
"name": "profile-job",
"profile": "support",
"schedule_display": "manual",
}
success, _output, response, error = sched.run_job(job)
assert success is True, f"run_job failed: error={error!r} response={response!r}"
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
assert observed["hermes_home_during_run"] == str(profile_home.resolve())
assert observed["scheduler_home_during_init"] == str(profile_home.resolve())
assert observed["scheduler_home_during_run"] == str(profile_home.resolve())
assert observed["skip_context_files"] is True
assert os.environ["HERMES_HOME"] == str(root)
assert sched._get_hermes_home() == root
def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env(
self, isolated_cron_profile_home, monkeypatch
):
import cron.scheduler as sched
root, profile_home = isolated_cron_profile_home
scripts_dir = profile_home / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "print_home.py").write_text(
"import os\nprint(os.environ.get('HERMES_HOME', ''))\n",
encoding="utf-8",
)
monkeypatch.setattr(sched, "_hermes_home", None)
job = {
"id": "script1",
"name": "profile-script",
"profile": "support",
"script": "print_home.py",
"no_agent": True,
}
success, _doc, response, error = sched.run_job(job)
assert success is True, error
assert response.strip() == str(profile_home.resolve())
assert os.environ["HERMES_HOME"] == str(root)
assert sched._get_hermes_home() == root
def test_run_job_without_profile_leaves_hermes_home_untouched(
self, isolated_cron_profile_home, monkeypatch
):
import cron.scheduler as sched
root, _profile_home = isolated_cron_profile_home
observed: dict = {}
self._install_agent_stubs(monkeypatch, observed)
job = {
"id": "noprof",
"name": "no-profile-job",
"profile": None,
"schedule_display": "manual",
}
success, *_ = sched.run_job(job)
assert success is True
assert observed["hermes_home_during_init"] == str(root)
assert os.environ["HERMES_HOME"] == str(root)
def test_run_job_rejects_missing_runtime_profile(
self, isolated_cron_profile_home, monkeypatch
):
import cron.scheduler as sched
root, _profile_home = isolated_cron_profile_home
monkeypatch.setattr(sched, "_hermes_home", None)
with pytest.raises(FileNotFoundError):
sched.run_job(
{
"id": "missing-profile",
"name": "missing-profile-job",
"profile": "missing",
}
)
assert os.environ["HERMES_HOME"] == str(root)
class TestTickProfilePartition:
def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch):
import threading
import cron.scheduler as sched
profile_job = {"id": "a", "name": "A", "profile": "default"}
parallel_job = {"id": "b", "name": "B", "profile": None}
monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_job, parallel_job])
monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)
calls: list[tuple[str, str]] = []
def fake_run_job(job):
calls.append((job["id"], threading.current_thread().name))
return True, "output", "response", None
monkeypatch.setattr(sched, "run_job", fake_run_job)
monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None)
monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None)
monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None)
n = sched.tick(verbose=False)
assert n == 2
ids = [job_id for job_id, _thread_name in calls]
assert ids.index("a") < ids.index("b")
main_thread_name = threading.current_thread().name
profile_thread_name = next(thread for job_id, thread in calls if job_id == "a")
assert profile_thread_name == main_thread_name

View file

@ -55,6 +55,7 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["maps", "blogwatcher"],
profile="default",
clear_skills=False,
)
)
@ -63,6 +64,7 @@ class TestCronCommandLifecycle:
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
assert updated["profile"] == "default"
cron_command(
Namespace(
@ -75,12 +77,14 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=None,
profile="",
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
assert cleared["profile"] is None
out = capsys.readouterr().out
assert "Updated job" in out
@ -96,6 +100,7 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["blogwatcher", "maps"],
profile="default",
)
)
out = capsys.readouterr().out
@ -105,3 +110,4 @@ class TestCronCommandLifecycle:
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
assert jobs[0]["profile"] == "default"

View file

@ -281,6 +281,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
result["enabled_toolsets"] = job["enabled_toolsets"]
if job.get("workdir"):
result["workdir"] = job["workdir"]
if job.get("profile"):
result["profile"] = job["profile"]
return result
@ -303,6 +305,7 @@ def cronjob(
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
profile: Optional[str] = None,
no_agent: Optional[bool] = None,
task_id: str = None,
) -> str:
@ -369,6 +372,7 @@ def cronjob(
context_from=context_from,
enabled_toolsets=enabled_toolsets or None,
workdir=_normalize_optional_job_value(workdir),
profile=_normalize_optional_job_value(profile),
no_agent=_no_agent,
)
return json.dumps(
@ -503,6 +507,10 @@ def cronjob(
# Empty string clears the field (restores old behaviour);
# otherwise pass raw — update_job() validates / normalizes.
updates["workdir"] = _normalize_optional_job_value(workdir) or None
if profile is not None:
# Empty string clears the field (restores old behaviour);
# otherwise pass raw — update_job() validates / normalizes.
updates["profile"] = _normalize_optional_job_value(profile) or None
if no_agent is not None:
# Toggling no_agent on/off at update time. If flipping to True,
# we need a script to already exist on the job (or be part of
@ -656,6 +664,10 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
"type": "string",
"description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated."
},
"profile": {
"type": "string",
"description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile and temporarily sets HERMES_HOME before loading .env/config.yaml and running the job. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep process-global profile state isolated."
},
},
"required": ["action"]
}
@ -710,6 +722,7 @@ registry.register(
context_from=args.get("context_from"),
enabled_toolsets=args.get("enabled_toolsets"),
workdir=args.get("workdir"),
profile=args.get("profile"),
no_agent=args.get("no_agent"),
task_id=kw.get("task_id"),
))(),