Skip to content

refactor(talon): move MCP onto langchain.mcp - #6091

Draft
John Kennedy (jkennedyvz) wants to merge 23 commits into
sydney-runkle/code/mcp-fastmcp-portfrom
jkennedyvz/talon/langchain-mcp
Draft

refactor(talon): move MCP onto langchain.mcp#6091
John Kennedy (jkennedyvz) wants to merge 23 commits into
sydney-runkle/code/mcp-fastmcp-portfrom
jkennedyvz/talon/langchain-mcp

Conversation

@jkennedyvz

Copy link
Copy Markdown
Contributor

Talon now uses the new MCP stack in langchain.mcp, including FastMCP protocol negotiation, elicitation through LangGraph interrupts, and cache-aware tool discovery.


This stacked PR migrates Talon off langchain-mcp-adapters while preserving its owned configuration, server status, tool filtering, runtime reload, channel authorization, OAuth storage, and Slack/GitHub device-flow behavior.

MCP tool calls are marked and wrapped through Talon middleware so optional empty arguments remain normalized and channel authorization stays bound to the originating tool call. OAuth callbacks and exception handling are updated for MCP v2.

Dependencies:

  • langchain>=1.4.0,<2.0.0 provides langchain.mcp.MCPAdapter.
  • fastmcp>=4.0.0b4,<5.0.0 provides transport, connection, protocol, elicitation, and cache support.
  • mcp>=2.0.0,<3.0.0 provides the compatible protocol and OAuth contracts.

These replace langchain-mcp-adapters; using the standard library or existing Talon dependencies cannot provide the new MCP protocol implementation. The same versions are already vetted by the parent MCP migration’s passing CI, CodeQL, Corridor, and Socket checks, and all are actively maintained under permissive licenses.

Validation:

  • make format
  • make lint
  • uv run --group test pytest --disable-socket --allow-unix-socket tests/test_mcp.py tests/test_mcp_auth.py tests/test_mcp_middleware.py tests/test_host.py tests/test_runtime.py tests/test_main.py --timeout 10 --no-cov -q (154 passed)

Stacked on #5922.

Made by Open SWE

Auto now defaults classifier reviews to Luna for OpenAI, Sonnet for
Anthropic, and Flash for Gemini when no classifier is explicitly
configured.

---

Using a lower-latency classifier by default avoids the 20-second Auto
review budget being consumed by the main agent's heavier model. Explicit
CLI, environment, and `config.toml` classifier choices still take
precedence; explicit inheritance and unsupported providers continue to
use the main model. Provider defaults also remain subject to the
existing model allowlist.

Made by [Open
SWE](https://openswe.vercel.app/agents/cf3960ad-23cf-5f6a-9cbb-69454500c549)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Removes 13 change-detector tests from the #5974 audit whose assertions
are redundant or do not protect a meaningful contract. One additional
implementation-coupled compact-backend test is replaced with behavioral
coverage, for a net removal of 13 tests.

Requirements marked for rewrite remain covered, including external
sandbox limits and heredoc framing, timeout headroom, middleware
configuration and backend behavior, summarization defaults and
token-counting performance, artifact paths, provider profile
registration, environment ordering, and version lookup caching. The
Nemotron profile assertion now checks critical middleware capabilities
without pinning their exact ordering.

Made by [Open
SWE](https://openswe.vercel.app/agents/c8a6359c-ecee-56cd-95da-34a01b827f1e)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Release PRs now keep only the newest generated-entry warning visible and
provide copyable release-bot commands.

---

Repeated release-please refreshes can produce a new stale-entry warning
for each head and fingerprint, leaving old notices visible and obscuring
the current action.

After publishing the current warning, this minimizes older
workflow-authored marker comments as `OUTDATED` through GitHub GraphQL
`minimizeComment`. Contributor-authored marker copies are excluded. It
also renders `@release-bot draft` and `@release-bot apply` in separate
fenced blocks so either command can be copied without creating an
ambiguous two-command comment.
Curated release-note applies no longer rerun unchanged package CI after
the prior release head has passed.

---

A bot-authored `apply curated release notes` commit only changes the
managed package `CHANGELOG.md`, but it currently restarts package CI and
cancels the in-flight run it depends on.

Queue CI runs for release-please branches, recognize only the trusted
bot's exact single-changelog apply commit, and carry forward the
completed parent `CI Success` result. Any identity, path, commit-shape,
configuration, or parent-check mismatch fails closed to normal CI.

Made by [Open
SWE](https://openswe.vercel.app/agents/b0abb0d0-8090-5c48-a575-f888f4f8ee06)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: bluefateludi <154300142+bluefateludi@users.noreply.github.com>
Co-authored-by: ccurme <chester.curme@gmail.com>
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.66](deepagents-code==0.1.65...deepagents-code==0.1.66)
(2026-09-03)

### Features

- Defaulted general-purpose subagents to fork mode
([#6024](#6024)).
- Defaulted the Auto classifier by provider
([#6039](#6039)).
- Added support for binding conversations to a recorded workspace
([#5946](#5946)).
- Added tracing for the effective approval mode
([#5972](#5972)).

### Bug Fixes

- Fixed request-time working directory binding and now show the bound
workspace in shell approvals
([#5968](#5968),
[#5966](#5966)).
- Fixed marketplace plugin installs to accept bare relative source paths
and identify the rejected source path when validation fails
([#5959](#5959),
[#5960](#5960)).
- Classifier errors now include model names
([#6038](#6038)).
- Updated the splash screen to use a sharp border
([#5970](#5970)).

### Performance Improvements

- Reduced QuickJS middleware tracing overhead by omitting trace inputs
([#6015](#6015)).

_End release notes preview._

---

> [!NOTE]
> A **community contributors** list and a **Special thanks** section
(crediting the users who filed the issues this release's PRs closed) are
appended to the GitHub release notes automatically at publish time (see
[Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 3).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Appending output to a long transcript now renders only a bounded tail
instead of replaying all hidden messages.

---

When messages below the visible transcript window had been dropped from
view, every new real row synchronously hydrated the entire hidden tail
before mounting. Assistant and reasoning rows also re-rendered markdown
during that pass, making append latency grow with conversation length.

This changes append-time reconciliation to jump directly to a
protected-safe tail window, mount only missing tail rows, remove
obsolete mounted rows, and preserve out-of-view history in the store and
spacers. If an active streaming or live-tool row would be virtualized,
reconciliation safely stays on the current window.

Made by [Open
SWE](https://openswe.vercel.app/agents/28e9d0e1-2487-5a1c-a67d-1930117ced4d)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Release-bot `draft` and `apply` commands now show `👀` while running and
`🚀` after successful completion.

---

The previous acknowledgement reply added timeline noise without showing
whether processing had completed. This replaces that reply with
identity-checked reactions on the triggering comment. Reaction calls use
the existing short-lived GitHub App token, retry transient failures, and
remain best-effort so they cannot invalidate a successful release-note
operation. Failed commands retain `👀` for visibility.
The server now refuses a second workspace when a sandbox is configured.

---

A sandbox is process-wide: it is created for one workspace, and sharing
it with another can expose the wrong setup and state.

The first workspace to request a runtime claims the sandbox for the life
of the process. Any other workspace is refused with HTTP 409:

```
Cannot host this workspace because a runtime for another workspace already exists and the configured sandbox is process-wide.
```

A failed sandbox build keeps the claim, because the process cannot prove
that no sandbox was created. If a sandbox build fails while serving a
request, the workspace route reports HTTP 503 instead of the process
exiting and taking the server down for every thread.

When the server process cannot start (for example, an unusable workspace
cwd), the parent now scrapes:

```
DEEPAGENTS_STARTUP_ERROR:ValueError: workspace.cwd is unavailable: /gone
```

instead of reporting only `Server process exited with code 1`.

Servers without a sandbox are unchanged and still serve several
workspaces. Per-workspace sandboxes remain unimplemented.

Made by [Open
SWE](https://openswe.vercel.app/agents/efb82cfb-37a6-53fb-a6d6-f7440eeeac96)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Each validated conversation workspace now keeps its own project `.env`
values without changing the server process environment.

A workspace is a conversation's validated working directory: the
client's `cwd`, resolved to its project root, fingerprinted, and bound
to the conversation thread. Two threads are in different workspaces when
those directories differ.

---

The server can host conversations from different workspaces at the same
time. Process-wide dotenv reloads could expose one workspace's model
settings, credentials, tracing settings, or tool configuration to
another workspace.

This change builds one immutable environment snapshot for each validated
workspace. It applies launch environment values first, then the nearest
project `.env`, then the global profile `.env`. Existing denied-key
checks still apply.

Runtime construction and supported lazy model paths use that snapshot.
Local shell execution receives a frozen copy with environment
inheritance disabled. Web search receives a workspace-bound Tavily
client. Stored model credentials remain paired with their stored
endpoint without writing to `os.environ`.

The implementation keeps the existing serialized dotenv reload for the
single-workspace client. It does not use process-wide reloads in the
multi-workspace server because runtime builds and offload work can
overlap. It also does not reject later workspaces, because the server is
designed to host them concurrently.

Arbitrary third-party code that reads `os.environ` during invocation is
not isolated by this change. Supported internal consumers keep the
workspace snapshot explicitly.

Made by [Open
SWE](https://openswe.vercel.app/agents/4ac38986-5a23-52f1-86a3-dde84db3c4fd)

## References
- Plan:
https://openswe.vercel.app/agents/4ac38986-5a23-52f1-86a3-dde84db3c4fd/plan

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
## Problem

Talon's cron only expressed elapsed time — `CronSchedule.parse` accepted
`in 30m` and `every 15m` and nothing else. A daily reminder had to be
approximated as `every 1440m`, which is anchored to the instant the job
was created and **drifts an hour at every DST transition**. A job firing
at 08:00 EST fires at 09:00 EDT from March onward and never recovers.
There was no timezone handling anywhere in talon; everything was UTC.

## Change

Two new schedule forms:

| Form | Kind | Example |
| --- | --- | --- |
| `at <YYYY-MM-DD> <HH:MM> <tz>` | one-shot | `at 2026-09-04 13:30
America/New_York` |
| `daily at <HH:MM> <tz>` | recurring | `daily at 08:00
America/New_York` |

The IANA zone is **required** and stored on the job, so a daily run
holds its local wall-clock time regardless of where the host is:

```
daily at 08:00 America/New_York
  2026-03-07 -> 13:00Z   (08:00 -05:00)
  2026-03-08 -> 12:00Z   (08:00 -04:00)   <- spring forward, still 08:00 local
  2026-11-01 -> 13:00Z   (08:00 -05:00)   <- fall back, still 08:00 local
```

Each run is rebuilt from the local *date* rather than advanced by 24
hours, which is what makes that hold. Two DST edge cases resolve
deterministically:

- A local time **skipped** by a spring-forward transition snaps forward
to the first minute that exists — `daily at 02:30` fires at 03:00 local
on that day rather than being skipped. Verified on a 30-minute gap too
(`Australia/Lord_Howe`).
- An **ambiguous** fall-back time takes its earlier (`fold=0`)
occurrence, so the job fires once, not twice.

Legacy POSIX aliases (`EST5EDT`, `EST`) and bare UTC offsets (`+02:00`)
are rejected. They resolve through `ZoneInfo` but carry no future
daylight-saving rules, so a recurring job under one would silently
drift.

A one-shot `at` already in the past is rejected at create and edit time,
with the resolved instant and the current time in the message so the
caller can correct it.

## Drive-by: interval advance

`_advance_claimed_job` duplicated the interval arithmetic that
`CronSchedule.next_after` already owned. Both now live in `next_after`,
which takes the previous run so interval jobs stay **phase-locked**
across a late scheduler tick (an `every 15m` job ticking at +15m40s
still lands on +30m, not +30m40s).

The old catch-up loop walked one iteration per missed interval — an
`every 1m` job after a month of downtime was 43,200 iterations inside a
synchronous store write. It is now O(1) arithmetic.

## Compatibility

Interval schedules serialize to exactly the keys written before this
change, and `from_dict` defaults a missing `form` to `"interval"`, so
existing `jobs.json` files load untouched — covered by a legacy-fixture
test.

⚠️ **Not forward compatible:** wall-clock records omit `minutes`, so a
*downgraded* talon cannot read a file containing one.

Adds `tzdata` so `zoneinfo` resolves on hosts without a system tz
database (Windows, slim containers). Pure-data wheel from the CPython
release team, Apache-2.0, no code.

## Testing

`make test` (316 passed), `make lint`, `make type` all clean. 32 new
cron tests covering both DST directions, the nonexistent and ambiguous
local times, a sub-hour gap, timezone rejection cases, the mixed-case
zone regression, interval phase preservation, long-downtime catch-up,
the strict-`>` guard against infinite refire, and the legacy `jobs.json`
load.

## Follow-up (not in this PR)

Nothing tells the agent what today's date is or which zone the user is
in, so it cannot reliably turn "after lunch tomorrow" into `at
2026-09-04 13:00 <tz>`. Talon now requires the zone explicitly, so a
wrong guess fails loudly instead of drifting — but per-turn clock
grounding is a separate change.

`openwiki/.claims/integrations/talon.json` pins SHA-256 hashes of line
ranges in `talon.md`, which this PR edits. That claim's evidence hash is
now stale; there is no documented regeneration tooling, so I left the
file alone.
Talon now loads MCP runtime configuration without importing
`deepagents-code`, using only `~/.deepagents/.mcp.json` or a
`DEEPAGENTS_TALON_MCP_CONFIG` override.

---

Related: JKB-68

This adds a small Talon-owned JSON loader around
`langchain-mcp-adapters`, preserves environment interpolation, reports
per-server connection failures, and removes coding-agent-style project
config discovery entirely. OAuth CLI migration remains scoped to JKB-69,
web tools to JKB-64, and final dependency cleanup to JKB-65.

Validation:
- `make -C libs/talon format`
- `make -C libs/talon lint`
- `make -C libs/talon test TEST_FILE='tests/test_mcp.py
tests/test_fleet_import.py' COV_ARGS='--no-cov'` (25 Python tests and 14
WhatsApp bridge tests passed)

Made by [Open
SWE](https://openswe.vercel.app/agents/311d9cf2-54e8-5459-843c-780b732fe6ea)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Talon's MCP login and config commands now run without importing
`deepagents-code`.

---

Related: JKB-69

Depends on #6066.

This replaces the dynamic `dcode` command import with a Talon-owned
OAuth login flow built on the Python MCP SDK. Login and runtime
authentication share Talon's single config-path contract and URL-bound
credential store; callback state remains validated by the SDK,
credential writes are atomic and private, and login errors avoid
exposing callback or token material. Interactive login no longer applies
the tool-load timeout while waiting for human authorization, and MCP SDK
OAuth failures are returned as sanitized CLI errors.

Validation:
- `make -C libs/talon format`
- `make -C libs/talon lint`
- `make -C libs/talon test TEST_FILE='tests/test_mcp.py
tests/test_mcp_auth.py' COV_ARGS='--no-cov'` (19 Python tests and 14
WhatsApp bridge tests passed)
- Talon MCP CLI import smoke test with every `deepagents_code` import
blocked

Made by [Open
SWE](https://openswe.vercel.app/agents/445ecd56-f074-570e-b182-12e99f5d6d03)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Talon operators can now complete MCP OAuth from WhatsApp, Telegram, and
other interactive channels without terminal access.

---

Depends on #6070
Related:
https://linear.app/langchain/issue/JKB-69/talon-replace-dcode-mcp-oauth-and-config-cli-integration

OAuth was still coupled to terminal input after the MCP configuration
work in #6070. This follow-up gives Talon a typed host authorization
protocol so sensitive authorization URLs, pasted callbacks, codes,
state, and tokens stay outside model context and traces.

The host binds each pending flow to the configured server, exact tool
invocation, channel provider, conversation, initiating operator, and
expiry. An MCP interceptor suspends and resumes the original tool call
for reactive reauthorization, while the narrow
`authenticate_mcp_server(server_name)` tool handles first-time login.
Successful first-time login refreshes discovered MCP tools on the next
channel turn.

Tests cover WhatsApp and Telegram routing, sender and provider mismatch
rejection, expiry and cancellation cleanup, callback interception,
invocation binding, suspended-call resumption, grouped startup errors,
bounded tool output, and runtime tool refresh.

Validation:
- `make test` — 322 Python tests and 14 WhatsApp bridge tests passed
- focused Ruff checks passed
- changed Talon source files passed `ty check`

Made by [Open
SWE](https://openswe.vercel.app/agents/960ff70d-e1d7-5b76-b6c0-f4cba2c4a53e)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Deep Agent <agent@deepagents.dev>
Talon omits empty optional string arguments before sending MCP tool
calls, preventing servers from treating placeholder empty IDs as real
identifiers.

---

Some models include empty strings for optional MCP arguments. Talon
forwarded them verbatim, which caused Vanta to reject calls such as
listVulnerabilities with an empty integrationId.

This adds schema-aware normalization at the MCP interceptor boundary. It
preserves required arguments and explicitly non-string fields while
leaving structured MCP results and errors unchanged.

Tests: make lint; make test.

Co-authored-by: Deep Agent <agent@deepagents.dev>
> **Stacked on #6062.** Base is `talon-wallclock-schedules`; review that
first. GitHub retargets this to `main` once #6062 merges.

## Problem

#6062 made wall-clock cron schedules require an explicit IANA timezone,
with no default zone. That is right for the scheduler, but it left the
agent holding a bag it could not fill: asked "remind me daily at 8am",
it had no way to learn which zone that means, or even what today's date
is.

Nothing in the repo filled that gap. A sweep of every package found no
`@tool` reporting the time; `deepagents_code/local_context.py` injects
directory, git, package-manager and runtime context but **no date, time,
or timezone** (and is cached in private state, refreshed only after
summarization, so it is the wrong vehicle for a live clock).
Date-into-prompt injection exists only in `examples/`.

## Change

`current_time`, registered unconditionally in `_build_tools`:

```python
current_time()                    # host's local zone
current_time("Europe/Berlin")     # read the clock elsewhere
```

```
{ "utc": "2026-09-03T21:23:33+00:00", "local": "2026-09-03T17:23:33-04:00",
  "date": "2026-09-03", "time": "17:23:33", "day_of_week": "Thursday",
  "timezone": "America/New_York", "utc_offset": "-04:00", "abbreviation": "EDT" }
```

The `timezone` value is in exactly the vocabulary the schedule parser
accepts, so it pastes straight into `daily at 08:00 <timezone>`. That
round-trip is verified end to end.

## Recovering the host's IANA name

This is the only fiddly part. The standard library has **no API** for
it, and the obvious candidate is a trap:
`datetime.now().astimezone().tzname()` returns `EDT` — an abbreviation
with no future DST rules, which #6062's parser rejects outright.

New `timezones.py` reads `TZ`, then the `/etc/localtime` symlink, then
`/etc/timezone`, validating each candidate and falling through when one
is unusable (so a `TZ=EST5EDT` host does not yield a name cron would
reject). The symlink target is split on the **last** `zoneinfo/`
component, because the layout varies:

| target | key |
| --- | --- |
| `/var/db/timezone/zoneinfo/America/New_York` | `America/New_York`
(macOS) |
| `/usr/share/zoneinfo/Europe/Berlin` | `Europe/Berlin` (Debian) |
| `../usr/share/zoneinfo/Asia/Kolkata` | `Asia/Kolkata` (relative link)
|
| `/some/copy/of/a/tzfile` | `None` |

**When no source resolves**, the tool still reports the correct local
time and UTC offset but returns `timezone: null` plus a note to ask the
user — rather than claiming UTC and silently scheduling the wrong hour,
which is the failure this whole line of work exists to prevent.

## Shared validator

`timezones.py` also takes over `resolve_zone` from `cron/jobs.py`, so
the tool and the scheduler share one validator instead of diverging.
`_resolve_zone` is now a four-line wrapper re-raising `TimeZoneError` as
`CronJobError`, so cron's error contract and all its existing tests are
untouched — that is the guard the extraction was safe. This is why
`jobs.py` is net negative.

## Testing

`make test` (348 passed), `make lint`, `make type` clean.

The environment mapping and both filesystem paths are injectable, so
tests cover every detection source and platform path shape **without
reading the real `/etc`**. Also covered: `TZ` precedence and POSIX `:`
prefix stripping, fall-through on an unusable `TZ`, the null-zone path,
`local`/`utc` describing the same instant, rejection of unknown zones /
legacy aliases / bare offsets / traversal keys, and that error text
names the rejected input but discloses no host path or traceback.

Registration is asserted two ways: added to the existing tool-wiring
assertion, and a new test with `cron_store=None,
include_web_tools=False` proving the tool is genuinely unconditional.

The CLI passes only `tools=mcp.tools` and leaves `include_web_tools`
default, so registering inside `_build_tools` reaches the CLI path with
**no change to `__main__.py`**.

## Note

`openwiki/.claims/integrations/talon.json` pins SHA-256 hashes of line
ranges in `talon.md`, which this edits. That claim's evidence hash is
stale; there is no documented regeneration tooling, so the file is left
alone.
Talon can now authenticate Slack's hosted MCP server while leaving
workspace selection to Slack on every authorization.

---

Slack's hosted MCP endpoint uses a public client and a pre-registered
callback that differ from the generic MCP OAuth flow. This adds the
provider-specific client metadata and recognizes Slack's callback port
without storing or injecting a workspace ID.

Channel callback routing recognizes both exact Talon callback endpoints
while provider and state validation remain authoritative.

Tests cover provider selection, callback validation, and callback
routing.

Made by [Open
SWE](https://openswe.vercel.app/agents/6e7f7ae4-3119-5ba0-b16a-683539d47998)

---------

Co-authored-by: Deep Agent <agent@deepagents.dev>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Talon users can authenticate MCP servers that advertise RFC 8628 device
authorization, including GitHub hosted MCP, from the CLI or a connected
channel.

---

The MCP SDK continues to own protected-resource discovery, issuer
selection, and standard authorization-code OAuth. When the discovered
authorization-server metadata advertises the device grant, Talon
switches at the redirect boundary and completes the device flow instead.
Other servers keep the existing authorization-code behavior.

GitHub retains one narrow bootstrap because its OAuth server does not
support dynamic registration: Talon seeds its public client only for the
exact HTTPS `api.githubcopilot.com` origin. Explicit login forces fresh
authorization, and a rejected stored token can restart device
authorization after the server returns 401.

OAuth metadata and endpoints are restricted to the validated HTTPS
issuer origin, responses are bounded, and credentials are persisted
atomically.

Tests: `make test`; `make lint`

---------

Co-authored-by: Deep Agent <agent@deepagents.dev>
dcode now reveals a persistent stale-install header after seven days and
surfaces update availability as a durable in-session message instead of
a transient toast.

---

Hourly update checks can discover that an install has become stale after
the TUI has already started, but the previous header was only composed
during startup and periodic notifications disappeared after 12 seconds.
Keep the header mounted so successful runtime checks can update its
subtitle and visibility, preserve explicitly enabled headers, and retain
notification-center install actions while deduplicating update messages
per target version for the current session.

### Persistent message examples

The first message shows manual `/update` and `ctrl+n` actions; the
second shows restart guidance when auto-update is enabled.

![dcode persistent update message
examples](https://github.com/langchain-ai/deepagents/releases/download/deepagents-code%3D%3D0.1.66/dcode-persistent-update-messages.png)

Made by [Open
SWE](https://openswe.vercel.app/agents/8bc7c47b-dba0-5090-8836-7fe24196ff8b)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
`_ticker` awaited `tick_once` with no handler around it:

```python
while not self._stopped.is_set():
    await self.tick_once()
    try:
        await asyncio.wait_for(self._stopped.wait(), timeout=self.tick_seconds)
    except TimeoutError:
        continue
```

A tick reads the store, consults the clock, and dispatches due jobs.
`_run_due_job` already catches failures from the `run_job` and
`deliver_result` callbacks, but a raise from any store call or from the
clock propagated straight out of the loop. The asyncio task then
completed with an exception and **the scheduler went quiet for the life
of the process** — no job fired again. Nothing awaits that task except
`stop`, so the only trace was an "exception was never retrieved" warning
at collection time, if it surfaced at all.

## The fix

Log and continue. The cost is one missed scan: due jobs stay due, so the
next tick picks them up.

Two details that aren't incidental:

- **The interval wait still happens after a failure.** Putting the guard
around only `tick_once` and leaving the wait outside it means a
persistently broken tick retries on the normal cadence instead of
spinning hot.
- **`Exception`, not `BaseException`.** `asyncio.CancelledError` derives
from the latter, so cancellation still propagates and `stop` keeps
working unchanged. There's a test for that specifically, since it's the
kind of thing a later "broaden the catch" edit would quietly break.

The failure detail goes out through `log_event`, which runs fields
through `_redact_mapping` and then `json.dumps` — so untrusted text read
off the store can't forge a log line, and secret-looking keys are
redacted. Same shape as the existing `cron.failure` and
`cron.delivery_failure` events.

## Testing

Two new tests, 398 pass total. The main one drives the real
`start`/`stop` loop with a store whose `due_jobs` raises twice and then
recovers, and asserts the ticker kept scanning, emitted exactly two
`cron.tick_failure` events, and went on to dispatch the job. I verified
it fails against unpatched `scheduler.py` — the raw `RuntimeError`
escapes — so it's actually pinning the behavior rather than passing by
construction.

`ruff check`, `ruff format --check`, and `ty check` clean.

## Relationship to #6086

Independent — different files, no shared code, merges cleanly in either
order. #6086 removed the cron store as a *source* of throws into this
loop, which is what surfaced the gap; this PR guards the loop itself,
which is still worth doing on its own since the clock, the logging path,
and the dispatch calls can all raise too.
`jobs.json` held every structured value as text and re-derived it on
each load — ISO-8601 timestamps, `"HH:MM"` local times, `"YYYY-MM-DD"`
local dates. The wall-clock fields were parsed twice, once validating on
deserialization and again inside every `next_after`, and the store
reparsed the whole file on every access, so one job firing cost three
full reads and two fsynced rewrites.

## The format

A versioned envelope whose every computed-from field is an integer:

```json
{ "version": 2, "jobs": [ { "...": "...", "created_at": 1788548916, "next_run_at": 1788609600,
    "schedule": { "form": "daily", "kind": "recurring", "hour": 8, "minute": 0,
                  "timezone": "America/New_York",
                  "display": "daily at 08:00 America/New_York" } } ] }
```

Schedules are a tagged union on `form`. `interval` carries `minutes`;
the wall-clock forms carry `timezone`/`hour`/`minute`, and one-shot `at`
adds `year`/`month`/`day`.

`display` stays as the one string, carried verbatim and never parsed —
so the agent's own phrasing survives a round trip. Dropping it would
have turned `every 2h` into `every 120m`, since that form isn't
derivable from `minutes` alone.

## Where the time went

Beyond the format, the access pattern was doing the real damage. The
parsed list is now cached against the identity of the inode it came
from, taken via `fstat` on the open handle rather than a second `stat`
of the path, so a concurrent atomic replace cannot make the cache claim
newer content than it holds. `_write_jobs` seeds it directly; reads hand
out a shallow copy so callers can't mutate it. The zone lookup is
`lru_cache`d — it ran on every deserialization and again per fire —
bounded at 128 because names arrive in agent-supplied text.

| jobs | idle tick | one job fire | cold read |
|---|---|---|---|
| 10 | 0.114 → **0.044ms** | 1.03 → **0.75ms** | 0.114 → 0.119ms |
| 100 | 0.421 → **0.046ms** | 2.49 → **1.23ms** | 0.421 → 0.498ms |
| 1000 | 3.900 → **0.050ms** | 19.0 → **6.39ms** | 3.900 → **4.707ms** |

Fire is the real `due_jobs` → `advance_next_run` → `mark_job_run`
sequence. The "before" column for it is computed from measured per-op
costs, since the old code had no cache and every read was a full parse.

**Cold read is ~20% slower at 1000 jobs, and that's real.** Fields are
now type-checked individually — work the old loader skipped entirely,
where `data["id"]` would surface a raw `KeyError` from inside the
scheduler. A first pass was worse still (6.14ms) because every field
re-validated the same mapping; hoisting that to one `_as_record` per
record recovered most of it. It's now paid once per process instead of
three times per fire.

## Behavior changes

- **Timestamps are whole seconds.** `_coerce_utc` truncates on the way
in, so disk round trips are exact rather than lossy. Cron granularity is
one minute, so nothing real is lost.
- **An unreadable store logs and reads empty instead of raising** — for
any cause, not just a version mismatch. This one matters more than it
looks: `scheduler._ticker` has no handler around `tick_once`, so a throw
out of `_read_jobs` would have silently killed the ticker for the life
of the process. (A guard on the ticker itself is a separate PR; it
doesn't depend on this one.)
- **No v1 compatibility path.** A file at any other version is discarded
with a warning naming the version found, and healed by the next write.
Existing schedules on disk are lost on upgrade — acceptable while talon
is experimental, and the alternative was a dead reader path lingering
indefinitely.
- **`to_dict` is disk-only.** The new `to_wire` keeps ISO-8601
timestamps and `HH:MM` local times, so what the model reads through the
cron tools is byte-identical to before. Only tool calls pay that
formatting; the scheduler's hot path never touches it.

Unchanged: file permissions (`0o700` dir, `0o600` file), the atomic
`mkstemp` + fsync + `replace` write, and the origin-scoping that keeps
one conversation from reading or editing another's jobs.

## Testing

418 pass, 24 new. Coverage for the envelope, phrasing preservation,
second-precision round-trips, eight malformed-input shapes (v1 bare
list, wrong versions, bad JSON, partial and impossible dates, `true`
where a count belongs), self-healing on the next write, cache
hit/miss/external-write/coherence-through-a-fire, and that reads don't
expose the cached list. `ruff check`, `ruff format --check`, and `ty
check` clean.

## Not in scope

`fcntl` advisory locking. The read-all/mutate-one/write-all pattern
already offered no cross-process guarantee; the cache narrows the window
slightly but doesn't create the problem. Worth a follow-up if multiple
processes ever share a `cron_dir`.
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Replace the legacy MCP adapters with FastMCP transport management and LangChain's MCP adapter while preserving Talon's configuration, authorization, status, filtering, and reload behavior. Update OAuth callbacks for MCP v2 and bind channel authorization to tool calls through middleware.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
@github-actions github-actions Bot added dcode Related to `deepagents-code` deepagents Related to the `deepagents` SDK / agent harness labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

This PR title scope does not match the package directory it changes.

Title scope(s): talon

Touched package dir(s) not covered by those scopes:

  • package label dcode from libs/code/
  • package label deepagents from libs/deepagents/
  • package label evals from libs/evals/

This check is blocking because the PR title declares one package scope while the changed files live in a different package directory.

Release-please consequence: release-please attributes commits by file path, not title scope. A bump-worthy title (feat/fix/etc.) that touches multiple managed packages opens a separate release PR per package. Bypassing this gate does not stop that fan-out.

To resolve

Edit the PR title scope so it covers the changed package directory (for example, use fix(code): ... for libs/code/**), or move the files so they match the declared scope.

If this is a feature plus incidental cross-package dependency/lock churn, split into:

  1. One feature/fix PR scoped to the single package that owns the user-facing change.
  2. One chore(deps): ... PR for the cross-package dependency / lockfile churn (chore is hidden and does not open release PRs).

If intentional

Apply the allow-scope-mismatch label. The check re-runs and passes with an informational note — confirm you still want a release PR for every managed component the diff touches.

📖 Multi-component fan-out

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file github_actions PR touching `.github` internal User is a member of the `langchain-ai` GitHub organization refactor Code change that neither fixes a bug nor adds a feature labels Sep 5, 2026
@github-actions github-actions Bot added size: XL 1000+ LOC talon labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

This PR edits a project README but is not a docs PR.

  • libs/talon/README.md

Change the PR title type to docs if the PR is documentation-only. Otherwise, apply the readme: acknowledged label to confirm these landing-page changes are intentional.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedpypi/​deepagents-code@​0.1.65 ⏵ 0.1.6675 +1100100100100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` deepagents Related to the `deepagents` SDK / agent harness dependencies Pull requests that update a dependency file github_actions PR touching `.github` internal User is a member of the `langchain-ai` GitHub organization refactor Code change that neither fixes a bug nor adds a feature size: XL 1000+ LOC talon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants