Skip to content

v2.0.0

Choose a tag to compare

@sagi5060 sagi5060 released this 06 Aug 18:43
· 84 commits to dev since this release

The release where agentdeck becomes a platform rather than a harness. Every turn — chat
or workflow — now runs on one Runtime and leaves one canonical event log behind, which is
what makes the rest of this list possible: a run you can pause, resume or cancel from
another process; an approvals inbox that survives a restart; a log you can point at
Postgres or Redis and share between workers; status and progress a client can render
instead of inferring. The v1 Python API, the .agentdeck/ layout and the SSE wire are
unchanged and verified against recorded baselines, so a v1.2.1 project keeps working.

Known limits worth reading before you upgrade, each with an issue rather than a footnote:
run control covers agent runs — a workflow run has no safe point yet, so it pauses
through its own interrupt/resume instead (#128). Telemetry still flows through v1's
tracer, not the event-stream sink, so the b4 note claiming Langfuse covered workflow runs
described a sink nothing had wired (#124). The HTTP approvals inbox and
App.pending_interrupts() read different sources and will disagree if you drive
approvals through both (#120). There is still no auth on the endpoints (#25) and no
tenancy — one tenant, one principal.

Added

  • Pause, resume and cancel a run that is already in flight, by run_id, from
    Python or over HTTP:

    await app.pause_run(run_id, reason="operator stepped away")
    events = await app.resume_run(run_id)
    await app.cancel_run(run_id, reason="user closed the tab")
    POST /runs/{run_id}/pause    {"reason": "..."}  -> {"run_id", "verb", "recorded": true}
    POST /runs/{run_id}/cancel   {"reason": "..."}  -> {"run_id", "verb", "recorded": true}
    POST /runs/{run_id}/resume   {"reason": "..."}  -> {"run_id", "status", "events"}, 409 if not paused
    

    Asking is not stopping, and the log says both. pause_run and cancel_run
    record a request and return; they cannot tell you when the run will stop, because
    it may be halfway through a tool call. So a controlled run writes
    control.requested, then control.observed(safe_point) once it reached a safe
    point, then the effect — run.paused / run.cancelled / run.resumed. Watch the
    events for the effect to learn that it stopped. A request leaves the run
    running; only the effect moves it.

    Nothing is force-killed. A signal is honored at the next safe point — between
    two stream items, so the chunk in flight is always delivered whole — and a tool
    call already running is never interrupted: the call finishes and the run stops
    before the step that would have used its result. safe_point on
    control.observed is what distinguishes "cancel took eight seconds" from "cancel
    took eight seconds because a tool call did".

    A paused run is suspended in the log, not parked in a process. The worker is
    free to exit, and any worker sharing the event store can lift the pause. Because
    there is no stack to return to, resuming re-enters the engine with the run's own
    input and the log as history: same run_id, seq carrying on. Work the paused
    turn had already done can therefore happen again
    — the model is asked again, and
    a tool it had already called may be called a second time, so keep tools idempotent
    and put side effects behind ctx.idempotency_key. Exactly one caller can resume a
    paused run; a second gets nothing rather than a second turn. Cancel is terminal
    and cannot be resumed, and paused stays distinct from waiting_human (that one
    resumes with a value).

    Cancelling a paused run works, with one caveat worth knowing. Pause, think,
    give up is the ordinary path, and a cancel recorded against a paused run is
    honored by the next resume — which ends the run cancelled rather than playing it
    on, so a resume can never quietly override whoever cancelled. But a paused run has
    no loop reaching safe points, so nothing else can turn that request into an effect:
    a paused run that nobody ever resumes stays paused, holding its session until
    AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS takes it over.

    Races are no-ops, never errors. A signal that arrives after the run ended does
    nothing and records nothing; the same pause sent twice is one request; resuming a
    run that is not paused returns nothing (409 over HTTP).

    Pending signals live in a control port, which is in-process by default — one
    worker can control its own runs, a second worker cannot see them. Set
    AGENTDECK_CONTROL_BACKEND=sqlite and AGENTDECK_CONTROL_URL=<file> for signals
    that cross processes, which is also the file
    agentdeck runs signal <run_id> <cancel|pause|resume> --control-db <file> [--reason ...] writes to. Agent runs honor safe points today; a workflow
    (LangGraph) run has none yet, so pause and cancel do not reach one.

  • A run can say what it is doing — two new event kinds, status.reported (a
    human-readable line: "Searching GitHub") and progress.reported (a named stage,
    optionally counted: step="Reviewing issues", current=2, total=4), so a client can
    show a long run's activity instead of inferring it from tool calls. Both are
    advisory: they carry no meaning for the platform, and a run's status still
    folds from its lifecycle events alone — a run that reports is still RUNNING, and
    neither kind is terminal.
    Emitters reach the stream through the run context, which they already have:
    await ctx.reporter.status("Searching GitHub") and
    await ctx.reporter.progress("Reviewing issues", current=2, total=4). An
    openai-agents function tool gets that context as the SDK's own — declare a first
    parameter of type RunContextWrapper[RunContext] and use wrapper.context.reporter.
    A langgraph node declares a config: RunnableConfig parameter and reads
    config["configurable"]["reporter"] (the key is
    agentdeck.adapters.engines.langgraph.REPORTER_KEY). Nothing imports the Runtime, and
    a RunContext built outside a run has a reporter that validates and drops.
    current past total raises immediately, at the call, whether or not a Runtime is
    listening. Reports are recorded in order, always before the run's terminal event, and
    the CLI reference renderer prints them ([status] … / [progress] … (2/4)).
    Three honest limits. A report is written at the engine's next event, so one emitted
    inside a single long tool call surfaces when that call ends rather than while it
    runs — enough for a client to show what a run has been doing, not enough to narrate a
    single slow call as it happens. Reports are best-effort: more than 64 waiting at once
    are dropped with a warning rather than growing without bound, and a report the event
    log refuses is dropped too, because an advisory event is never worth failing a run
    that would otherwise have completed.
    Reading a stream that contains them needs no change: a reader older than this release
    parses both as UnknownEvent and skips them, and because neither is a lifecycle kind
    a mixed-version deployment folds status identically on both sides.

  • control.requested and control.observed (agentdeck.core): run control
    is now three events, not one. control.requested(verb, reason=None) records
    that a signal was written; control.observed(verb, safe_point) records that the
    run reached a safe point and is acting on it; the effect stays the kind it
    always was (run.cancelled, run.paused, run.resumed, input.appended). So
    "we asked it to stop" and "it stopped" are finally different facts in the log —
    under cooperative control they can be seconds apart, and safe_point
    (stream_item, tool_dispatch, node_boundary) says what the run was in the
    middle of. One pair of kinds carries every verb — cancel, pause, resume,
    steer — so pause/resume and mid-run steering add no further vocabulary when
    they ship. Neither kind is a status transition: a request leaves a run RUNNING
    until its terminal event says otherwise, and neither is terminal.
    The vocabulary landed first and the producers followed in the same release (see
    the pause/resume/cancel entry above): a run that is signaled now emits both
    kinds. The CLI renderer prints both phases and the Langfuse sink puts them on the
    run's timeline.

  • run.resumed now carries the answer it was resumed with, as content
    (value: list[ContentBlock] | None) — content passes through as sent, a string
    arrives as a TextBlock, any other JSON answer as a DataBlock, and lifting an
    operator's pause carries nothing. Stored in full, like a run's own input,
    because a truncated answer cannot be replayed. This is what makes a
    previously unrecoverable window repairable: the single write that moved a run
    from waiting_human to running recorded that it was answered and not what
    the answer was, so a process dying between that write and the engine consuming
    the value left the log saying running while the engine was still parked at its
    interrupt — every later resume then rejected as stray, with no recovery but a
    manual one. The answer is now in the log at the instant the claim commits, before
    the engine is asked for anything, so a successor process has what it needs. The
    repair itself is not built here
    — nothing yet reads value back to bring an
    engine into line — so treat this as the prerequisite, not the fix. An answer JSON
    cannot carry (an arbitrary object, a datetime, NaN) is logged as a warning and
    recorded as no value rather than failing a resume that would otherwise work; such
    a run keeps the old stranding risk.
    Compatible in both directions, and measured rather than assumed: a run.resumed
    written before this release still parses (no value means none), and a 2.0.0b4
    reader handed one of the new events parses it and drops the field it does not
    know — no listing or dashboard outage like the one DataBlock caused, and the
    new kinds arrive as unknown kinds a consumer skips. The one caveat is what that
    dropping implies: only a process new enough to see value can use it to
    repair a resume, so upgrade the workers that reconcile before relying on it.

  • UnknownBlock (agentdeck.core): a content block of a type this version
    doesn't recognize now falls back to UnknownBlock(type, raw_block) — keeping the
    raw block for a store to hold and a consumer to skip — instead of rejecting the
    whole event, mirroring how an unknown event kind already becomes UnknownEvent.
    Closes the one asymmetry DataBlock (#101) exposed: ContentBlock was a strict
    discriminated union, so a reader older than a new block type raised on the entire
    event rather than skipping the block, and because SqliteEventStore.list_runs
    deserializes a run's last lifecycle row in one comprehension, one such event in a
    shared store could fail a whole tenant's listing. A malformed known block still
    raises. Measured against origin/dev's own ContentBlock, not asserted: that
    reader really does reject a block type this addition introduces, and this tree's
    reader parses the same wire event, keeps the raw block, and leaves status_of and
    the terminal invariant unchanged (tests/core/test_old_reader_block_compat.py).

Changed

  • A run reads its pending control signal at most once every 200ms, instead of
    once per streamed item. A 500-chunk answer used to cost 500 control reads whose
    answer was "no" 499 times — one file read each with the SQLite control port, and a
    network round trip each once the port is shared. Measured at a real model's pace
    (~30ms a chunk), a 400-chunk answer now costs 58 reads instead of 400.
    What this trades is latency, not correctness: a cancel is noticed up to 200ms
    after it is recorded, and still acted on at a safe point, never mid-token. The
    first safe point of a run always reads, so a signal that beat the run out of the
    gate is honored immediately. Anyone who was relying on the previous
    read-every-item behavior — a test asserting a cancel lands within a stream shorter
    than 200ms, for instance — can pass Runtime(..., control_poll_interval=0) to get
    it back at the old read cost.
  • A sink the breaker disables is no longer disabled for good. A telemetry
    endpoint that failed five events in a row used to be dead for the rest of the
    process; now the dispatch waits 30 seconds and then lets one event through to
    see whether it is back. A sink that takes that event starts receiving the
    stream again; one that fails it keeps its events dropped and is offered
    another event 30 seconds later, so a genuinely dead endpoint costs two emit
    attempts a minute rather than one per event. Coming back is logged as loudly as
    going away was, and says how many events the outage dropped, so a stream that
    resumes mid-run is not a gap with nothing to explain it. The cooldown is a
    deadline read off a clock and never a wait — a run is not slowed by a sink's outage or by
    its recovery — and nothing is replayed: the events the outage covered are
    still lost, and still counted as drops. A sink therefore needs no retry logic
    of its own for a transient outage, and one that cannot lose events reads the
    event log, which is the complete copy.
  • A flapping sink can no longer flood the log with stack traces. Failure
    logging was rate-limited per failure streak, which bounded nothing for a
    sink that fails every other event — each success reset the streak, so every
    failure printed a fresh traceback and the run's length decided the log
    volume. Tracebacks are now limited to one per sink per 60 seconds, and each
    one reports how many failures went unlogged since the last, so a throttled
    log still says how much it is standing in for. The breaker's disable decision
    is unchanged by this.
  • The workflow HTTP endpoints run on the v2 Runtime. POST /workflows/{name}/run, GET /workflows/{name}/pending and POST /workflows/{name}/{thread_id}/resume were the last surface still calling v1's
    runner directly, so a workflow turn left no event log behind at all — it
    streamed to the caller and vanished. Every workflow turn is now recorded like a
    chat turn: one run in the log, node updates, stream writes, interrupts and the
    final state, readable by the same listings, replays and dashboards. The wire is
    unchanged — the same node_update / custom / interrupt / done SSE frames
    and the same JSON bodies, checked against the recorded baselines rather than by
    inspection.
    Three consequences worth knowing before upgrading. A workflow's thread_id is
    now its session, and a session runs one turn at a time, so posting a second
    run to a thread whose previous turn has not finished answers 409 instead of
    interleaving two turns over one graph state. Read "not finished" broadly: a
    thread sitting idle on an unanswered approval is not finished either, and holds
    its session until somebody answers it — so the case an approval UI actually hits
    is a 409, for as long as the approval goes unanswered (or until that run has been
    silent for AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS, one hour by default,
    after which the next turn takes the session over). A resume against a thread with
    no paused run answers 404 where it previously surfaced v1's runner error.
    And a get_stream_writer() write now reaches the log as a namespaced custom
    event (langgraph.stream_write) on its way to the unchanged custom frame.
    A durable = True workflow still resumes on the configured checkpointer — the
    bridge plays v1's own compiled graph, which carries it — and the [durability]
    extra stays optional for a project that only chats.
  • The HTTP approval inbox and App.pending_interrupts() are now separate sources
    of truth
    , and will be until they are joined. GET /workflows/{name}/pending
    and the HTTP resume project the event log; App.pending_interrupts(),
    App.due_resumes() and App.tick() still read the graph's checkpointer. They
    agree only as long as one of them is used: an interrupt created headlessly is
    invisible over HTTP, and one answered headlessly leaves an entry behind in the
    HTTP listing. Answering such a leftover entry over HTTP is a 404 rather than
    the stale final state a replayed thread would otherwise hand back, so no answer
    is silently dropped — but a deployment that drives approvals through both doors
    will see the two listings disagree. Joining them — routing the Python API's inbox
    through the Runtime too — is tracked in #120.
  • The v2 LangGraphEngine (not v1's endpoints, whose final state always came from
    ainvoke) now reports a final state for a graph compiled without a
    checkpointer, which it previously could not: the terminal state is read from the
    run's own event stream instead of from a checkpoint that never existed.

Removed

  • agentdeck.runtime.REPO_ROOT / agentdeck.runtime.settings.REPO_ROOT — it only
    ever pointed at the repo root in a source checkout and at the installed package's
    site-packages directory otherwise; nothing in agentdeck needs that path, and
    nothing outside it should have depended on it either. (#16)
  • agentdeck.runtime.ENV_FILE / agentdeck.runtime.settings.ENV_FILE — was a path
    frozen at import time (see Fixed below for why that was itself unsafe); replaced by
    resolve_env_file(), resolved fresh every time get_settings() actually builds a
    Settings object. (#16)

Fixed

  • .env and config.yaml now resolve from the project's current working
    directory, not from wherever agentdeck itself is installed.
    Previously
    both were located relative to runtime/settings.py's own file path, which
    is the repo root in a source checkout but lands inside site-packages once
    agentdeck is pip installed as a dependency — so a consumer project's
    .env (API keys, OPENAI_MODEL, …) was silently ignored, typically
    surfacing as OPENAI_API_KEY ... must be set despite a valid .env in the
    project. If you were exporting the same values as real shell/CI
    environment variables to work around this, nothing changes
    — a real env
    var still outranks the file. But if you have a .env sitting unused next
    to an installed agentdeck, it will now take effect. .env is also now read
    at first use rather than at import agentdeck time, so a chdir between
    importing the package and first building settings still resolves against the
    right project. (#16)
  • Two bundles of the same kind (two agents, or two workflows) exporting a
    class of the same name used to collapse silently into one invocable, in
    sorted bundle order — copying agents/greeter/ to agents/greeter-v2/ to
    iterate and forgetting to rename the class made the original vanish from the
    registry with no error, no warning, no log line. App.load() (and anything
    that discovers a project, including InvocableRegistry) now raises
    ConfigError naming both bundle paths and the class name they share. A
    project relying on the old shadowing to hide one bundle behind another now
    fails at load instead of routing requests to the wrong agent; rename one of
    the classes to fix it.
  • A bundle whose agent.py or workflow.py raises while importing (a
    SyntaxError, a missing dependency, anything at module scope) used to
    surface as a raw traceback through the import machinery. It's now a
    ConfigError naming the offending bundle path, with the original exception
    chained as the cause.