refactor(talon): move MCP onto langchain.mcp - #6091
refactor(talon): move MCP onto langchain.mcp#6091John Kennedy (jkennedyvz) wants to merge 23 commits into
langchain.mcp#6091Conversation
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.  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>
|
⛔ This PR title scope does not match the package directory it changes. Title scope(s): Touched package dir(s) not covered by those scopes:
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 ( To resolveEdit the PR title scope so it covers the changed package directory (for example, use If this is a feature plus incidental cross-package dependency/lock churn, split into:
If intentionalApply the |
|
⛔ This PR edits a project README but is not a
Change the PR title type to |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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-adapterswhile 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.0provideslangchain.mcp.MCPAdapter.fastmcp>=4.0.0b4,<5.0.0provides transport, connection, protocol, elicitation, and cache support.mcp>=2.0.0,<3.0.0provides 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 formatmake lintuv 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