Commit Graph

160 Commits

Author SHA1 Message Date
Arvin Xu c866610586 ⚡️ perf: make step completion state snapshots opt-in (#19682)
* ⚡️ perf: remove unused final state from step completion events

* ⚡️ perf: make step state snapshots opt-in per agent run

* 🐛 fix: use a valid FinishReason in the finish executor test

`'done'` is the resulting state status, not a FinishReason, so tsgo failed
type-check on the new opt-in snapshot test. Use `'completed'` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:46:57 +08:00
Arvin Xu 33244b9a92 🐛 fix: normalize agent message errors and structured IM replies (#19676)
* 🐛 fix: normalize agent message errors and structured IM replies

* 💄 style: make chat error details opt-in

* 💄 style: unify error details background and compact narrow layouts

* ✅ test: assert normalized message errors in persistence integration
2026-09-19 20:20:13 +08:00
Arvin Xu 119c09ab39 ♻️ refactor(mecha): decide the model parameters of an LLM call in the shared core (#19586)
* ♻️ refactor(mecha): decide the model parameters of an LLM call in the shared core

Move the model-parameter rules out of the server's per-attempt hints into
`@lobechat/mecha` as `resolveModelParams(request, providers)`: which extend
params the model consumes (user row → provider card → canonical card),
where the reasoning effort comes from (topic pin, only for the pinned
model and, in a group topic, the answering agent → model-instance config →
sub-agent override), when assistant reasoning must be replayed (Kimi,
DeepSeek thinking, Meta, card support, provider fallback), the capability
defaults the context may assume, the history window, and the stream flag.

The server adapter now only supplies lookups (`createServerModelParamsProviders`)
and the run's frozen snapshots. `stream` falls back to the agent's
`chatConfig.enableStreaming` when the operation did not pin one; the
DeepSeek model-id predicates move to a dependency-free module so the shared
package can import them without the model bank.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): trace the streaming mode the request really uses

The chat span recorded `ctx.stream ?? true` while the payload now honours
the agent's chat config, so a newly non-streaming request was labelled as
streaming in telemetry. The built context rides along on the LLM trace
input and both the payload and the span read the same resolved value.
Also point the add-model-provider integration map at the shared
resolver as the policy owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 21:39:34 +08:00
Arvin Xu 237392c468 ♻️ refactor(mecha): decide which context facts a step needs in the shared core (#19576)
* ♻️ refactor(mecha): decide which context facts a step needs in the shared core

Move the "what to gather" rules for the per-step context (topic references,
agent documents, onboarding, workspace links, plan/todo, credentials,
Composio services, agent/group builder, agent management, official tools,
sandbox and identity variables) into `@lobechat/mecha` as
`gatherContextFacts(request, providers)`. Hosts now only answer typed
lookups (`ContextFactProviders`); the server adopts it and its twelve
providers collapse into one fetcher factory.

Rule alignment with the browser, as agreed in review:
- plan block is injected on the server when a plan document exists;
  history still wins for the TODO state
- credentials are only fetched with `lobe-creds` and use the workspace's
  organization creds inside a workspace
- Composio services are gated on the creds tool and the deployment feature,
  disabled ids come from the run's tri-state `disabledPluginIds`
- onboarding follows the server rule, including the already-injected check
- topic references skip when the context is already injected
- agent-builder documents target the edited agent on both hosts
- the agent-management context (available agents / providers / plugins) is
  now actually injected on the server; the previous computation was dead
- official tools include LobeHub skill connectors on the server
- share-visitor gates live in the shared rules, keyed on the principal

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🔒 fix(mecha): keep the owner's agents, providers and plugins away from a share visitor

Auto skill mode still enumerated the creator's other agents into
<available_agents> for a visitor run. The agent-management facts are
owner-scoped, so they are skipped whenever the request carries a share
visitor; @-mentions still flow through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(server): offer installed custom MCP connectors in the agent-management context

The run's tool source map only classifies skill, Composio and client
tools, so filtering it for 'mcp' never yielded a custom connector and
createAgent / updateAgent could not offer one. Read the user's installed
custom plugins from the plugins table instead — pinned or not — which is
what the browser lists from its tool store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:51:42 +08:00
Arvin Xu 93fc45ff77 ♻️ refactor(agent-runtime): carve principal, plan and host slots out of state.metadata (#19513)
Third and last step of the state restructure (#19498 world/binding, #19505 origin). The remaining creation-time facts in `state.metadata` fall into three business groups, each now a typed slot on `AgentState`:

- `principal` — under whose authority the run acts and what it may do: `actor.{shareVisitor, bot, deviceScope}`, `policy.deviceAccess`, `audit.{clientIp, userAgent}`. Decided at creation and re-presented, never re-derived, at each dispatch boundary.
- `plan` — how the run executes: `execution` (the resolved `ExecutionPlan`), `skills`, `stream`, `eval`, `workingDirectory`. The duplicated `metadata.modelRuntimeConfig` is dropped in favour of the existing top-level `modelRuntimeConfig`.
- `host` — what the host needs to deliver and retry the run: `hooks` (serialized webhook configs) and `queue.{retries, retryDelay}`. Carried by the runtime without interpretation.

`ExecutionPlan` / `ExecutionPlanUnroutedReason` move to `@lobechat/types` so the runtime can type the slot; `src/helpers/executionTarget` re-exports them. `normalizeAgentState` lifts the legacy keys on load and folds a legacy `metadata.modelRuntimeConfig` into the top-level slot when it is absent. `HookDispatcher.dispatch` accepts the wire-shape `SerializedAgentHook[]` the slot carries and narrows to `SerializedHook` itself; `isAgentShareRun` takes the state; `resolveRunActiveDeviceId` / `resolveRunProjectSkills` read `plan` and `principal`; the dynamic intervention resolvers receive the plan's working directory alongside the ledger.

`metadata` is now the run ledger only: what the runtime and host write while the operation executes.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:18:32 +08:00
Arvin Xu 518059aed1 ♻️ refactor(agent-runtime): carve the typed origin slot out of state.metadata (#19505)
Second slot of the state restructure started in #19498. Every identity, trigger and lineage fact about a run — which conversation node it hangs on, who asked for it, whether it is a child and of whom — lived as loose keys in the untyped `state.metadata` bag, spread in from `appContext` at creation and read back by string key across the runtime package, the server executors, completion, abandonment and intervention paths.

`AgentState.origin` (`AgentRunOrigin`) now carries them, frozen at operation creation and mirroring the durable operation row:

- conversation node: agentId / topicId / threadId / groupId / sessionId / documentId / scope / taskId / userId / workspaceId
- trigger: trigger / sourceMessageId / defaultTaskAssigneeAgentId / signal (was `agentSignal`) / continuation (was `agentInterventionContinuation`)
- run tree: `lineage.{isSubAgent, orchestrationRole, parentOperationId, progressAnchor}` (was `subAgentProgress`)

`normalizeAgentState` is generalised to a key → slot-path table and lifts the legacy keys on state load, treating `null` legacy values as absent so the slots only ever hold `undefined`. `RuntimeExecutorContext`, `resolveRunActiveDeviceId` and the lifecycle helpers read `state.origin`; `CompletionLifecycle` threads the origin alongside the remaining metadata; `parseAgentSignalMarker` validates a raw marker so the completion payload extractor can read `origin.signal` while operation rows keep `metadata.agentSignal`. Audit facts (`clientIp`, `userAgent`) and the runtime-written intervention preparation stay in `metadata` for the principal / ledger slots.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:10:03 +08:00
Arvin Xu 73a0528546 ♻️ refactor(agent-runtime): carve typed world/binding slots out of state.metadata (#19498)
* ♻️ refactor(agent-runtime): carve typed world/binding slots out of state.metadata

Every reader of the prompt-side run context (agent config, group roster, project instructions, connector note, user memory/timezone, search decision, channel facts, eval context) and of the device binding (active device id/platform/system info) used to reach into the untyped `state.metadata` bag, so a value that was not spelled out in the creation-time field list silently disappeared.

`AgentState` now carries two typed slots derived from what those fields mean:

- `world` — what the model is told about the run's world. Frozen at operation creation; the context engine only reads it.
- `binding` — late-bound execution facts. The one business slot the host may rewrite at a step boundary (`computeDeviceContext`).

`normalizeAgentState` lifts the legacy metadata keys into the slots at the persistence boundary (Redis + in-memory state load) so blobs written before this change keep working, without carrying the data twice. `RuntimeExecutorContext` drops its per-field copies; readers go through `state.world` / `state.binding`, and `resolveRunActiveDeviceId` takes the state instead of the metadata bag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): read the trace header model from world.agent

The trace recorder still reached into `metadata.agentConfig` through an `as any` cast, which the key grep missed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ♻️ refactor(agent-runtime): declare the search decision shape instead of importing model-bank

The runtime package should not take a dependency on the model catalog for one five-field interface. `SearchDecisionSnapshot` is declared next to the world slot and is structurally identical to the `model-bank` resolver output, so the server keeps passing its value unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:34:35 +08:00
LobeHub Bot 189bd0abe5 🚀 release: sync main branch to canary (#19435)
Co-authored-by: Innei <i@innei.in>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
Co-authored-by: AmAzing- <115673583+AmAzing129@users.noreply.github.com>
2026-09-12 13:30:35 +08:00
Arvin Xu 8631512dde ✨ feat(agent-runtime): default extensible agent loop with an explicit stop reason (#19153)
Three hosts drive this runtime and each wrote its own loop: the server's
`executeSync`, the browser's `streamingExecutor`, and the package example. A
fourth is coming for device-hosted runs. They have drifted.

The server breaks explicitly on `interrupted`, on a parked status, and on
`shouldContinueExecution` — a private method that is the only complete
statement of the rules, covering cost limits and a missing next context. The
browser loops on `status !== 'done' && status !== 'error'` and escapes a
parked run only because its steps happen to return no next context. The two
agree today by coincidence, not by design: whoever changes what a parked step
returns will make the browser take another turn on a parked state.

`runAgentLoop` owns exactly two things — when to stop, and what context the
next step receives. It takes the step as a parameter rather than calling
`runtime.step` itself, because a step means very different things per host: on
the server it claims a distributed lock, persists state, dispatches hooks and
records a trace; in the browser and on a device it is a direct `runtime.step`.
Collapsing those is how a shared loop becomes something every host works
around, so persistence, hooks, streaming and quota deliberately stay outside.

The stop reason is the part worth having. Every host re-derived it after the
fact — the server to decide whether to emit `max_steps` completion signals,
the browser to choose between a terminal lifecycle event and the parked one.
Computed once, it cannot drift.

Migrating the example surfaced a real distinction: "no next context" describes
what a step PRODUCED, so it cannot be asked before the first step. A run may
legitimately open with no context, and checking it up front would end every
loop before it began. Hence `resolveTerminalReason` (status and cost, safe
before any step) alongside the full `resolveStopReason`.

No host is migrated here. The server's per-step distributed path — where
`executeStep` is scheduled and re-entered rather than looped — is NOT a loop
and must not adopt this; only its `executeSync` should, and separately, so a
regression is attributable.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:27:40 +08:00
Arvin Xu 18131e9f75 🐛 fix(agent-runtime): recover garbled tool names instead of ending the turn (#19362)
* 🐛 fix(agent-runtime): recover garbled tool names instead of ending the turn

When a model garbles the `____` separator in a tool name, `ToolNameResolver`
dropped the call, `GeneralChatAgent` saw no tool calls and no text, and the
operation finished as `done` with an empty assistant message — the conversation
just stops mid-task with no error anywhere.

Observed on tpc_t1n5DfJ0yqtv: glm-5.3-flash emitted
`lobe-local-system~~__runCommand` at step 4 after spelling
`lobe-local-system____runCommand` correctly at steps 0 and 2.

- `ToolNameResolver.resolve` now retries an unresolved call against a squashed
  form of the offered tool names (lowercase, alphanumeric only). Only an
  unambiguous match is accepted, and it runs strictly after the existing
  resolution path, so nothing that resolves today changes.
- When a call still cannot be resolved, `GeneralChatAgent` answers it with a
  rejected tool result (`tool_name_unresolved`) so the model can retry with a
  real name, instead of finishing silently. The feedback is capped at two
  rounds per operation, is skipped once `forceFinish` is set, and falls back to
  throwing so the failure lands on the dashboards as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent-runtime): make the unresolved-tool rejection actually reach the model

Two problems found in review of the previous commit.

The rejected tool rows were written under a parent assistant that carries no
`tools`, so they never came back: state messages are rebuilt from the DB on
every step, and conversation-flow's `collectToolMessages` returns early when
the parent lists no calls. The feedback the model needed was dropped on the way
in. `resolve_blocked_tools` now advertises those calls on the parent before
creating the rows, behind an explicit `unresolvedToolNames` payload flag so the
ordinary allow-list path keeps its single write.

The feedback budget counted matching tool rows in `state.messages`, which is
rehydrated history: rejections from earlier operations spent a new operation's
budget before it started, and two malformed parallel calls consumed both rounds
at once. It is now an operation-scoped counter on `AgentState`, incremented once
per instruction by the executor.

Also names the offending tools in the rejection text. The name the model reads
back from its own turn is regenerated from the persisted identifier/apiName
pair, which for an unparseable name is not what it emitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent-runtime): keep the thought signature on unresolvable tool calls

A garbled tool name does not imply a garbled signature. Gemini 3.x attaches
`thoughtSignature` to the call and 400s on the next tool-call turn unless it
comes back, so rebuilding the payload without it would break exactly the retry
the rejection exists to enable. Carried over like the normal resolver does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 01:56:19 +08:00
Rdmclin2 f2a0901ef1 🐛 fix: harden bot messaging and channel setup (#19405)
* 🐛 fix: harden bot messaging and channel setup

* 🐛 fix: retain bot messages across sender and replay races

* 🐛 fix: retry deferred bot replay independently

* 🐛 fix: preserve command turns and debounce owner lookup
2026-09-11 12:30:23 +08:00
Innei aba9e8150c 👷 build: migrate the test suite to vitest 5 (#19355)
* 👷 build: upgrade vitest to 5.0.0

Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.

* 👷 build: shim jest-dom matcher types for vitest 5

Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.

* 👷 build: migrate the test suite to vitest 5

Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).

* 👷 build: make the openapi knowledge-base FileService mock constructable

tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".

* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir

Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.

* 👷 build: refresh the desktop lockfile for vitest 5

apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.

* 👷 build: keep the pre-vitest-4 coverage exclusions explicit

Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.

* 🎨 style: format the migrated vitest configs

* 👷 build: keep canary's new class mocks constructable under vitest 5

exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
2026-09-10 22:18:49 +08:00
Rdmclin2 4bf05e2499 🐛 fix(bot): surface budget scope and headroom in bot failure replies (#19336)
* 🐛 fix(bot): surface budget scope and headroom in bot failure replies

Carry the budget context of an InsufficientBudgetForModel error from the
runtime lifecycle event through the hook payload into the bot reply, and
render a scope-aware message (member allowance vs. workspace pool) with
the remaining and required credits instead of the generic
"Agent Execution Failed".

LOBE-13726

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

* 🌐 style: update i18n

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

* 🔒 fix(bot): stop quoting the billed allowance in budget replies

Runs mentioned from a shared channel are billed to the bot owner, so a
reply that quoted availableCredits / requiredCredits published the
owner's balance to every channel member. Keep the scope-aware copy and
drop the amounts line; a regression test asserts no figure is rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

* 💬 fix(bot): address the bot owner and split plan-limit copy in budget replies

Runs are billed to the bot owner, not to whoever mentioned the bot, so the
budget copy no longer calls the reader the payer ("your budget") and
points at the bot owner / workspace admin instead. FreePlanLimit and
SubscriptionPlanLimit get their own copy (the allowance itself is
exhausted, so the fix is the plan, not a cheaper model), and the
budgetTypeAtError scope refinement now applies to all three admission
codes since the workspace gate throws SubscriptionPlanLimit when a run
can't be priced upfront.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

* 💬 fix(bot): cover both meanings of SubscriptionPlanLimit in the reply

Per the error taxonomy the code fires when the paid-plan allowance is
reached OR when the plan tier does not cover the requested model, so the
copy no longer claims the credits are spent and offers upgrade / top-up /
custom API as the remedies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

* 👷 build: serialize build scripts so editor postinstalls stop racing on lexical

Three peer-variant copies of @lobehub/editor patch the same hoisted
lexical files in postinstall; run concurrently they read each other's
half-written output and fail with "Patch context mismatch". Only the
allowBuilds packages run scripts, so childConcurrency: 1 costs seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPxmeT4DrAdKP3RUCYzgp

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:30:38 +08:00
YuTengjing dac6eb0fdc 🐛 fix: attach registered Works to the final assistant reply (#19345) 2026-09-09 21:36:49 +08:00
YuTengjing e9b1d082f3 🐛 fix: honor user media capabilities in server agent runs (#19296) 2026-09-09 13:30:25 +08:00
Innei a5e74a9eb4 ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path (#19294)
* ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path

Four package-level import edges put ~1.5 MB raw on the first screen:

- GlobalProvider/Editor.tsx imported @lobehub/editor/react, and rolldown does
  not shake that entry down to EditorProvider, so lexical, yjs and fuse landed
  in the entry chunk. The provider is reached by path until the package
  exposes a provider-only entry.
- @lobechat/types re-exported the ajv-backed AgentGraphSchema through the
  barrel that every store touches; it now lives on the
  @lobechat/types/agent/graph subpath and the agent barrel re-exports types
  only.
- @lobechat/context-engine's root barrel dragged the database models barrel
  (drizzle) in through AgentDocumentInjector; the app reaches the
  ./agent-document subpath and the injector reads loadPolicy directly.
- packages/locales bundled locales/en-US/*.json on top of the default
  sources it mirrors; the overlay now applies only in development.

Web production build: first-screen gzip 2299 KB -> 1968 KB.

Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH

* 🐛 fix(vite): keep one EditorContext in dev for the path-imported provider

Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH

* 🐛 fix(locales): refresh consumers after the dev English overlay lands

Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH
2026-09-09 02:45:25 +08:00
Arvin Xu 3905dd29de ✅ test: pin the polling regression with a fixed-count case (#18997)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:09:02 +08:00
Arvin Xu 84f749e7dc 🐛 fix: raise tool-call repeat limit from 5 to 20 (#18989)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:35:33 +08:00
Arvin Xu f6e47529f3 🐛 fix(agent-runtime): stop repeated tool call loops (#18965)
* 🐛 fix(agent-runtime): stop repeated tool call loops

* 🐛 fix(agent-runtime): preserve abort over loop guard
2026-09-01 15:29:35 +08:00
Arvin Xu f2784b1305 📈 feat(agent-runtime): record device vs transport time for dispatched tools (#18899)
* 📈 feat(agent-runtime): record device vs transport time for dispatched tools

A tool dispatched to a device takes ~2s, and today nothing says how much of
that is the tool and how much is getting there and back. The server can only
measure the whole round trip — publish → gateway → device → callback →
redis — and the device never reported its own duration, so the two are
indistinguishable in every trace we have.

That split is the input to a real decision: whether to move the agent loop
onto the device. If most of the 2s is transport, local execution collapses it
to a function call. If it is not, the same effort is better spent on the
server-side work each tool step already does (two message writes plus a full
`messages.query()` refetch) — an order of magnitude cheaper, and no runtime
port needed.

The device now times `executeToolCall` on its own clock and returns
`executionTimeMs`; the server carries it to the trace beside its own
observation. `lh trace op inspect` renders the split:

    └─ Tool  local-system  ✓  1.9s (device 40ms + transport 1.9s)

Since the step's own `executionTimeMs` brackets the whole tool step, the
remainder — step minus round trip — is the server-side message-write and
query cost, so all three numbers come out of one trace with no further
instrumentation.

Note the server's tool-result endpoint validates with `z.object`, which
strips unknown keys: the field had to be declared there or it would have been
dropped silently between gateway and redis. Everything is optional end to
end, so an older device, or an agent-gateway that does not yet forward the
field, degrades to the total alone rather than breaking. The CLI also logs
the duration in its daemon log, which is what lets us tell those two cases
apart once this is deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(desktop): report device tool timing from the desktop gateway too

Only the CLI responder was instrumented, so every tool dispatched to the
Electron app fell back to "total only" and could not produce the
device-vs-transport split. Desktop is where most device tool calls actually
happen, which would have biased the whole measurement toward the `lh connect`
subset — exactly the runs least representative of real usage.

Both desktop routes are timed (builtin tool and tunnelled stdio MCP), on the
success and failure paths alike: a tool that takes 30s to fail is as
interesting as one that takes 30s to succeed.

The repo has exactly two `sendToolCallResponse` call sites outside the client
itself; both are now covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:48:48 +08:00
YuTengjing 774e04c44b 🐛 fix: support media refs for tool result images (#18743) 2026-08-28 19:45:12 +08:00
Tsuki 1b6a2b740b ✨ feat(agent): align approval workflows with web (#18645)
* ✨ feat(agent): support durable heterogeneous interventions

* 🔒 fix(agent): keep review tokens out of query URLs

* ✨ feat(agent): align approval workflows with web

* ✅ test(agent): align approval runtime expectations

* ✅ test(agent): align client approval expectations

* 🐛 fix(agent): expose live activity batch terminality

* 🐛 fix(agent): harden durable approval recovery

* 🐛 fix(agent): reconcile approval claim state

* ✅ test(agent): type durable approval fixture
2026-08-26 22:25:05 -07:00
Arvin Xu dd122b4217 ✨ feat(achaos): add agent chaos infrastructure (#18667)
* ✨ feat(chaos): add agent chaos infrastructure

* ♻️ refactor(achaos): unify package namespace

* 🐛 fix(achaos): enforce fault lifecycle boundaries

* 🐛 fix chaos runtime retry semantics

* 🐛 harden chaos run outcome semantics

* 🔒 enforce chaos approval and activation

* 🐛 reconcile chaos injection lifecycle

* 🔒 bound destructive chaos recovery

* 🧪 separate chaos injection from recovery

* 🐛 validate chaos execution inputs

* 🔒 tighten chaos process boundaries

* 🐛 preserve chaos integration evidence

* 🔒 require cancellable chaos injection

* 🔒 sanitize and gate chaos evidence

* 🔒 preflight chaos safety configuration

* 🐛 retire aborted runtime faults

* 🔒 classify chaos environments safely

* 🔒 enforce chaos cleanup and mock ownership

* 🔒 make chaos outcomes unambiguous

* 🐛 stop hooks after chaos mock

* 🐛 release losing chaos claims

* 🔧 remove standalone achaos CI step

---------

Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
2026-08-26 00:17:32 +08:00
Arvin Xu 1dae711792 🐛 fix(tools): recover stale scoped tool calls (#18664)
Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
2026-08-25 12:35:34 +08:00
Arvin Xu bc9b212a21 ✨ feat(goal): add autonomous work recovery and final acceptance (#18635) 2026-08-24 12:48:41 +08:00
Arvin Xu 718a960fb3 🐛 fix(agent-runtime): prevent repeated context compression (#18626)
* 🐛 fix(agent-runtime): prevent repeated context compression

* 🐛 fix(agent-runtime): reserve prompt headroom after compression

* 🐛 fix(agent-runtime): honor recompression threshold override

---------

Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
2026-08-24 00:35:49 +08:00
Arvin Xu abbc941de9 🐛 fix(agent-runtime): remove compressed messages from queue payload (#18610)
Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
2026-08-23 15:19:15 +08:00
Arvin Xu c3ed4078e8 🐛 fix(agent-runtime): support slim compression payloads (#18587)
Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
2026-08-23 12:42:52 +08:00
Arvin Xu aa92ba239e 🐛 fix(agent-runtime): settle tool calls that were still running at an interrupt (#18506) 2026-08-23 11:49:41 +08:00
Rylan Cai 83b41ee7fb ✨ feat: forward eval tool calls to mock servers (#18516)
* ✨ feat: forward eval tool calls to mock servers

* 🐛 fix: forward eval tools in queued workers

* ✨ refactor eval tool forwarding config

* ✨ feat: forward eval tool calls through hooks

* 🐛 fix: preserve eval mock interception

* ♻️ refactor: shrink eval forwarding test diff

* 🐛 fix: type eval tool forwarding patches
2026-08-21 17:00:29 +08:00
Arvin Xu d04cddd3af ✨ feat(expertise): inject operation-scoped context (#18420)
* ✨ feat(expertise): inject operation-scoped context

* ♻️ refactor(expertise): colocate snapshot builder

* 🚧 chore(expertise): gate context injection behind lab

* ♻️ refactor(expertise): trim terminal state payloads

* ♻️ refactor(expertise): move injection gate into context engine
2026-08-17 16:07:56 +08:00
Arvin Xu 9396a5b315 ♻️ refactor(agent): move Graph Agent config from chatConfig to agencyConfig (#18419)
* ♻️ refactor(agent): move Graph Agent config from chatConfig to agencyConfig

A graph is an agent: the ReasoningGraph defines the agent's behavior body
(node policies, routing conditions, data contracts), so it belongs on the
agency config (how the agent behaves and executes) rather than the chat
config (per-session preferences).

- types: drop enableGraphMode/graph from LobeAgentChatConfig, add to
  LobeAgentAgencyConfig next to heterogeneousProvider
- runtime: GraphAgent factory reads agencyConfig.graph with a legacy
  chatConfig.graph fallback so existing rows keep running
- cli: --enable-graph / --disable-graph / --graph-file now write
  agencyConfig
- db: graph full-document replace moved to agencyConfig; legacy
  chatConfig.graph is forwarded to agencyConfig on the next write,
  migrating old rows in place
- openapi / sdk: graph fields moved into agencyConfig, regenerated

* ♻️ refactor(agent): rename ReasoningGraph to AgentGraph

The graph is the behavior body of a Graph Agent, not a 'reasoning' data
structure — the old name was misleading. Aligns the top-level type with
its children (AgentGraphNode / AgentGraphEdge / AgentGraphField) and the
AGENT_GRAPH_ROOT_NODE_ID constant.

- ReasoningGraph -> AgentGraph
- ReasoningGraphSchema -> AgentGraphSchema
- UI copy in locales updated to match

* 🐛 fix(agent): address codex review on graph agency config

- openapi: thread agencyConfig through createAgent / updateAgent and the
  public projection, so API clients can actually persist the graph instead
  of getting a silent no-op
- db: an explicit agency-level graph (including null) now takes ownership
  of the graph and drops the legacy chatConfig fields, so the runtime
  fallback cannot resurrect a cleared snapshot
- ui: AgentGraphRuntime falls back to the legacy chatConfig values so
  pre-migration rows render their effective graph instead of an empty,
  disabled editor
2026-08-17 15:24:42 +08:00
Rylan Cai 86991beb75 ♻️ refactor: inject graph runtime context continuously (#17761)
* ♻️ refactor: inject graph runtime context continuously

* ♻️ refactor: clarify graph node tool guidance

* ♻️ refactor: generalize runtime additional context

* ♻️ refactor: simplify additional context injection

* ♻️ refactor: tighten runtime context provider

* ♻️ refactor: group graph agent files

* ♻️ refactor: separate graph context hooks

* ♻️ refactor: compact graph prompt hook application

* ♻️ refactor: share JSON-safe conversion utility

* ♻️ refactor: keep runtime context at virtual tail

* ✨ refactor runtime additional context placement

* ♻️ remove unused virtual tail override

* 📝 rename runtime additional context types

* 🧹 clean up runtime executor context fixture

* ♻️ tighten runtime additional context contract

* 🧹 clean up runtime executor test diff

* 🧹 drop unnecessary context phase comment diff
2026-08-10 00:30:39 +08:00
Arvin Xu 435f5eb4d0 🐛 fix(agent): keep the topic running mark owned by the main run (#18039)
A `callAgent` / `callSubAgent` / group-member run executes in an isolation
thread on its SPAWNER's topic. Two unguarded writes let such a child take
over — and then destroy — the topic's `runningOperation`, which is the main
run's only gateway reconnect anchor:

- `execAgent` wrote the mark for every run, so the child overwrote the
  parent's mark with its own (thread-scoped) operation;
- `finish` → `ServerOperationStore.clearRunningMark()` cleared the mark with
  no ownership check, so the child's completion nulled it while the parent
  kept running.

From that moment the client had nothing to reconnect to: `useGatewayReconnect`
keys its SWR fetcher off the mark, so it never even requested a gateway token,
and the run drawer rendered the REST snapshot it opened with — frozen for the
rest of the run.

Observed in production on a 9-minute task run: the sub-agent finished 78s in
and the parent streamed for 6 more minutes with the mark already null. Over 24h
this hit 139 runs across 64 users, blind for ~9.5 minutes on average.

Fixes:
- `execAgent` skips the mark for isolation-thread runs (`appContext.isolationThread`);
  a child's live progress already rides the parent channel via `subAgentProgress`.
- `clearRunningMark` compares before clearing, mirroring the guard
  `AbandonOperationService` already applies.

Also pass the owning agent into `reconnectToGatewayOperation`: it fell back to
`chatStore.activeAgentId`, which is stale off the agent route and undefined on
home, so a reconnected run drawer streamed into a bucket no surface renders.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 05:09:40 +08:00
sxjeru f6ddb0675e 🐛 fix: stop retrying Google content blocks and end client runs immediately (#17937)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-08-05 20:57:12 +08:00
Arvin Xu c599cac242 🐛 fix(agent): resolve a parallel tool batch in one approval (#17838)
* 🐛 fix(agent): resolve a parallel tool batch in one approval

Approving one tool of a parallel batch resumed the run immediately while
its siblings were still empty `pending` rows, so the model saw blank tool
results and re-issued the whole batch.

Root cause: `GeneralChatAgent`'s pending guard only matched the raw
message shape (an `assistant` row plus sibling `tool` rows). The server
runtime rebuilds `state.messages` through `conversation-flow`'s `parse()`
on every step, where `FlatListBuilder` folds an assistant and its tool
rows into one `assistantGroup` — so the guard was a no-op server-side.
It now handles both shapes; the abort path uses a deliberately un-scoped
variant so it can settle every pending row it can reach.

On top of that, add a batch approval path end to end:

- `resumeApprovals[]` travels client service -> gateway -> tRPC (each
  target message authorized on its own) -> `AiAgentService`, which
  applies every decision and resumes with a single `call_tools_batch`,
  continuing the LLM exactly once with the complete result set.
- `existingToolMessageIds` lets the batch executor fill the pending rows
  the approval pause already created instead of inserting duplicates and
  orphaning the originals.
- The continuation assistant anchors on the assistant that emitted the
  batch — a step is one LLM call, and tool rows are inline data of that
  call, never spine nodes — so the parent chain no longer depends on
  which tool `Promise.all` happened to settle last.
- `requestHumanApprove` retires the assistant placeholder a resume seeded
  when it parks again, instead of leaving an empty "..." row behind.
- The intervention bar gets an "Approve all" action (batch only, behind
  the same resource gate as the per-card actions) that flushes pending
  argument edits before submitting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(agent): scope approve-all to one batch and settle aborted rows in place

Three follow-ups on the parallel-approval change.

**Approve-all could span turns.** `getPendingInterventions` walks the whole
conversation, so `interventions.length > 1` never meant "one parallel
batch" — an abandoned approval from an earlier turn sits in the same list.
Sending both to the server resolved them under one assistant anchor and
continued the model once with unrelated results, running a tool the user
never released into this turn. The bar now groups by the owning assistant
(`getInterventionBatch`) and only offers the action for the active card's
own batch; an entry with no resolvable owner is its own group. The server
refuses a mixed-owner batch outright, and now validates every decision
before applying any of them so a refused batch can't leave half its tools
marked approved with no run to execute them.

**Aborting a parked approval duplicated its rows.** Teaching
`extractAbortInfo` to see folded pending rows turned the server's
`resolve_aborted_tools` path from a silent no-op into an active write —
and that executor only ever inserted. The originals stayed `pending`, so
Stop left the approval cards on screen beside duplicate aborted rows. The
instruction now carries `existingToolMessageIds` and the executor settles
those rows in place. Settling one needs an intervention write, which the
message transport had no port for; `updateToolIntervention` adds it
(server → `updateMessagePlugin`, client → `optimisticUpdateMessagePlugin`,
which also mirrors onto the assistant's `tools[]` entry the card reads).
The `human_abort` phase deliberately keeps inserting: it aborts during LLM
streaming, before any tool row exists.

**A stale assertion in the server executor suite.** `RuntimeExecutors.test`
still pinned the tool-anchored contract for `call_tools_batch`; it drives
the same executor through the server host, so it fails against the
assistant anchor. Updated like its four siblings in `tool.test.ts`. It was
missed because `check --test` discovers tests from changed files and this
one reaches the executor through a thin wrapper in another package.

`InterventionTabBar` also renders in the global approval card, which offers
no batch action — count and handler are now one optional prop so "a button
whose label counts calls it won't resolve" is unrepresentable.

The unrelated `it.each` reformat in `RuntimeExecutors.test` is prettier
correcting pre-existing drift on a block adjacent to the edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(agent): let a run parked on tool approval be stopped

Review feedback on the acceptance turned an observation into a
requirement: a run parked waiting for tool approval had no way out — "从
这一步开始我不想 agent 继续做了".

Stopping is deliberately not rejecting. A rejection writes the reason
onto the tool row and then CONTINUES the model so it can respond to the
refusal; stopping ends the turn outright — nothing pending executes and
the model is not continued.

None of the existing cancel paths can reach a parked run. Parking emits a
stream-terminal signal, so the client marks its own operation `completed`
and prunes it ~30s later, `topic.metadata.runningOperation` is cleared,
and all four client cancel entry points filter on `status === 'running'`.
By the time the user decides to stop, the client no longer even knows the
parked operation id. Server-side `interruptTask` is also not enough: it
only flips runtime state, assuming a live loop will persist the outcome,
and a parked run has no loop — so both the operation row and the pending
tool rows would be left behind.

`stopPendingApproval` validates every target before writing any of them
(a half-settled batch clears some cards and strands the rest against a
run that is already gone), settles the existing tool rows IN PLACE
(inserting fresh aborted rows would duplicate every tool and leave the
originals `pending` — exactly what keeps the cards on screen), resolves
the parked operation from the topic via `findLatestParkedOperationId`
(scoped by user), and retires it.

The control sits in the intervention bar's footer rather than the tab
bar: the tab bar only mounts for multiple cards, and a run parked on a
single call is precisely where a way out matters most. Same
`canUseResource` gate as the per-card actions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 style(agent): put the approval stop button next to Submit

Review: "这个停止不应该放在提交边上的吗??" — it was at the far left of
the intervention bar's footer while Submit sat at the far right, split
across the whole bar with the choice list in between.

Stop and Submit answer the same question the card is asking, so they
belong in one action group. Right-aligning the bar footer was not enough:
that footer is `[stop][actions-portal]`, and the portal holds the choice
list AND Submit, so stop ended up beside the whole block rather than
beside the button.

Moved into `ApprovalActions`' own footer row, immediately left of Submit
— the only placement where the two are genuinely adjacent, since Submit
renders there. The bar goes back to a single portal container, and stop
now shares Submit's `loading / isMessageCreating / canUseResource`
gating for free.

`stopPendingApprovalForCard` resolves the card's own batch through the
same `getInterventionBatch` approve-all uses, so stopping still can't
discard an unrelated turn's pending approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:54:28 +08:00
Arvin Xu 9b9f6b96c8 🐛 fix(agent): stop todo lists resetting, and make grep results engine-independent (#17877)
* 🐛 fix(agent-runtime): carry todo state into server tool execution

Todos have two stores with different lifetimes: the topic's plan document,
which only exists once `createPlan` has run, and the tool message's
`pluginState.todos`, which is always written and is what the prompt side
reads back via `extractTodosFromMessages`.

The server tool-execution path passed neither into `PlanExecutionRuntime`,
so it resolved todos from the plan document alone. An agent that called
`createTodos` without `createPlan` therefore had no store at all: every
later `updateTodos` reloaded an empty list, its index-based operations fell
out of bounds and were silently skipped, and the tool answered
"No operations applied." with an empty list — which was then written back
into `pluginState`, wiping the message-history copy too. Observed in the
wild as an agent rebuilding the same 7-item list four times in one run.

Thread `currentTodos` from the runtime executors (rebuilt from message
history, mirroring how `activatedSkills` already travels) through
`ToolRunContext` and `ToolExecutionContext` into the plan runtime, and fix
`resolveExistingTodos` to test `?.length` — an empty array is truthy, so
the client path was short-circuiting the plan-document fallback on the
first call of every conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(local-file-shell): keep build output out of grep search results

`rg` and `ag` honour `.gitignore`, so build artifacts never reach a search
result. The `grep` branch and the Node fallback have no ignore-file
awareness and only excluded `node_modules` and `.git`, so the *same query*
returned wildly different results depending on which engine was picked.

That choice is made by a `which rg` probe, and a packaged Electron app
launched from Finder/Dock inherits launchd's minimal PATH — no homebrew
`rg`, so it silently falls through to `/usr/bin/grep`. Measured on this
repo, one `grepContent` call for `class KeyVaultsGateKeeper` returned 1
match under ripgrep and 108 under grep, 94 of them compiled bundles under
`.next/dev/server/chunks`. An engine downgrade became a change in results,
and the agent consuming those results had no way to tell.

Hoist the exclusion list into one `EXCLUDED_DIRS` constant covering the
usual build output and share it across all three engines plus the Node
fallback's glob ignores, so every engine answers the same question the same
way. Entries match relative to the search root, so explicitly scoping a
search into `dist` (etc.) still works — verified against rg and grep.

The Windows implementation composes via `super.getDefaultIgnorePatterns()`
and inherits this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(local-file-shell): make grep results independent of which engine ran

Follow-up to the previous commit, which fixed the symptom but named the wrong
trigger. Re-running the failing trace against the real implementation shows the
`.next` flood came from the **Node fallback**, not from `/usr/bin/grep`: the
polluted results were absolute paths, and only the Node path emits those.

What actually happens is that one call poisons every later one. `execa`'s `cwd`
must be a directory, so a `scope` naming a single file — a normal, documented
call — throws. The catch then assigned the downgraded engine to
`this.currentTool`, and the desktop keeps one `ContentSearchService` for the
whole app session, so from that point on every search in the process ran on the
Node fallback: no ignore files, absolute instead of relative paths, far slower.

Three consequences, all of which the agent silently reasoned over:

- **Path shape.** External tools run with `cwd = searchPath` and search `.`, so
  they emit `./rel/path`; the Node fallback emits absolute paths. In the trace
  the agent joined a `./src/...` result onto the scope it had passed and got
  `ENOENT` — the file really lived under `packages/database/`.
- **Ignore files.** `rg`/`ag` read `.gitignore`; `grep` and the Node fallback did
  not, so the same query returned build output from one engine and not another.
- **Stickiness.** A per-call failure permanently changed engine for the session.

Fixes, all aimed at one invariant — the engine must not change the answer:

- Search a file-scoped target from its parent directory instead of throwing.
- Fall back per call; only persist a downgrade when the binary is really gone.
- Normalise external-tool output to absolute paths. Every line starts with `./`
  in every output mode, so rewriting that prefix covers `-n`, context and count
  output without parsing separators. Applied to the Windows `rg` path too.
- Filter `grep` and Node-fallback results through `git check-ignore` — the real
  gitignore semantics (nested ignores, negations, `core.excludesFile`) rather
  than a reimplementation, and no new dependency. Best-effort: outside a repo or
  without git, results pass through unfiltered.
- Force the filename prefix (`-H`) when the target is a single file; these tools
  drop it otherwise, which would emit unattributable `12:text` lines.
- An explicitly scoped root beats the ignore rules, matching `rg <ignored-dir>`,
  so deliberately searching `dist/` still works on every engine.

`EXCLUDED_DIRS` stays as a cheap pre-filter so the Node fallback doesn't walk a
multi-GB `.next` before git filters it back out.

Verified against the real implementation on a git fixture: `rg`, `grep` and the
Node fallback now return byte-identical result sets, both at the repo root and
when scoped into an ignored directory. 4 of the 5 new tests fail before this
change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♻️ refactor(local-file-shell): type currentTodos properly and halve the gitignore cost

Two review points.

`ToolRunContext.currentTodos` was typed `unknown[]`, which forced an `as any` at
the one place that reads it. Both ends of that hop already agree on a shared
shape, so type it `StepContextTodoItem[]` from `@lobechat/types` — the same type
`ToolExecutionContext` declares — and drop the cast.

`filterGitIgnored` spawned `git` twice per search: once to ask whether the search
root was itself ignored, then once for the batch. Fold the first question into
the second by prepending `searchPath` to the same `--stdin` batch. Absolute
inputs are classified identically from any cwd inside the repo, so this also lets
the call run from `searchPath` instead of its parent — which matters, because for
a repo-root search the parent is outside the repo and git answers 128 for
everything, silently disabling the filter.

Measured over `packages/` (median of 5): the single spawn costs ~11 ms against a
568 ms `grep` / 447 ms Node-fallback search — about 2%. `rg` and `ag` never reach
this code, so the engine that normally runs pays nothing at all. Cost scales with
result count (~96 ms for 5000 paths); on the Node path it is partly self-funding,
since filtering the file list up front avoids reading files we would discard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(local-file-shell): stop hiding tracked dirs, keep cleared todos cleared

Both from Codex review on #17877; both reproduced before fixing.

**Tracked directories named like build output were unfindable.** The
`EXCLUDED_DIRS` list grew to cover `dist`, `build`, `out`, `.next`, … but those
names are only *usually* generated. This repo tracks
`apps/desktop/build/entitlements.mac.plist`, and `rg`'s `--glob` overrides all
ignore logic, so searching it went from 1 hit to 0 on every engine.

The list was always a guess at "what git ignores", and `filterGitIgnored` now
answers that exactly — including this repo's `.next`. So the guess is redundant
*and* wrong: narrow it back to `node_modules` and `.git`, two names nobody
checks in, and let git decide the rest. The `.next` flood this PR set out to fix
stays fixed, by the mechanism that can't be wrong about it.

This does mean the Node fallback walks a large ignored tree before git filters
it out. That path is now genuinely rare — it only runs when `rg`, `ag` *and*
`grep` are all missing, and the stickiness fix stopped searches falling into it
by accident — so correctness wins over the traversal saving.

**An explicitly cleared todo list could resurrect itself.** `resolveExistingTodos`
used `?.length`, conflating "caller supplied nothing" with "caller supplied an
empty list". `PlanRuntimeContext` documents only `undefined` as the fallback
signal, and after `clearTodos` message history legitimately reports `[]`. Since
`syncTodosToPlan` swallows its errors, the plan document can still hold the old
items, so the next todo call reloaded and resurrected exactly what the agent had
just cleared. Test `!== undefined` instead.

The earlier `?.length` was meant to revive the plan-document fallback on the
client path, where `currentTodos` is always supplied. That fallback being
unreachable there is correct, not a bug: on the client, message history *is* the
source of truth and the plan document is only its mirror.

Regressions added for both, including a tracked `build/` directory in the engine
parity fixture. 6 of the new assertions fail before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 07:29:51 +08:00
Rylan Cai 71e353ac1f 🐛 fix(agent-runtime): enforce scoped tool allowlists (#17717)
* 🐛 fix(agent-runtime): enforce scoped tool allowlists

* 🐛 fix(agent-runtime): resolve denied tools before approval
2026-07-30 14:43:57 +08:00
YuTengjing ba0bc6ccfe ✨ feat(chat): add copy operation id action to assistant message menus (#17710) 2026-07-30 10:54:09 +08:00
YuTengjing 57b088a1d3 🐛 fix: persist complete OpenAI Responses reasoning items (#17705) 2026-07-29 21:16:41 +08:00
YuTengjing 27b26a823a 🐛 fix: reuse assistant messages on step retry (#17703) 2026-07-29 18:33:51 +08:00
Rylan Cai 4e677af923 🐛 fix(agent): inject runtime todos into server context (#17671)
* 🐛 fix(agent): inject runtime todos into server context

* 🧪 test(agent): keep server todo fixture canonical
2026-07-29 16:44:25 +08:00
YuTengjing 91878a5d13 🐛 fix(agent-runtime): preserve intervention message ancestry (#17628) 2026-07-26 20:54:10 +08:00
YuTengjing 70f0b34d2d ✨ feat: support ChatGPT subscription authentication (#17527)
* ✨ feat: support ChatGPT subscription authentication

* 🐛 fix: preserve Codex reasoning state and client identity

* 🐛 fix: preserve reasoning source in agent state

* 🐛 fix: require ChatGPT account identity

* 🐛 fix: support Codex Responses Lite requests

* 🐛 fix: apply Responses request preparation consistently

* 🐛 fix: preserve server reasoning signatures

* 🐛 fix: enable ChatGPT reasoning replay

* 🐛 fix: preserve non-streaming reasoning signatures

* ✅ test: fix ChatGPT runtime type compatibility

* ♻️ refactor: type Responses reasoning provenance
2026-07-24 21:57:22 +08:00
YuTengjing 174b03976c 🐛 fix(agent): stop retries for empty completions (#17418)
* 🐛 fix(agent): stop automatic retries for empty completions

* 🐛 fix: address empty completion review feedback

* 🐛 fix: address empty completion review feedback

* 🐛 fix: surface empty completion cost
2026-07-22 11:18:19 +08:00
Arvin Xu e3d39e51d9 ♻️ refactor: unify agent runtime executor registry (#17265)
* ♻️ refactor: unify agent runtime executor registry

* 🐛 fix: preserve batch tool step context
2026-07-21 17:25:01 +08:00
YuTengjing 3a587d15e6 ✨ feat: add work version registry (#17079)
* ✨ feat: add work version registry (LOBE-10820)

* 🐛 fix: dedupe Work summaries per root operation instead of globally

* 🐛 fix: repair zod v4 record schema, bound rootOperationIds, redact work payloads in stream events

* ✅ test: align work summary tests with per-operation event semantics

* 🐛 fix: surface a Work summary only on the latest operation touching it

A Work touched by multiple rounds was chipped under every round's anchor
message. Dedupe summaries globally again so only the last touching round
carries the artifact chip.

* 💄 style: show per-version cost deltas in work version history

cumulativeCost is a per-operation running snapshot, so rendering it raw
made version rows look like they should sum to the card total but did not
(v3 already contained v2's spend). Diff snapshots within each operation so
rows show their own spend and visibly add up to the summary card.

* ♻️ refactor: registry-driven work type adapters with changeType/resourceLabel naming

- Rename work_versions.role -> change_type and works.resource_identifier -> resource_label across schema, migration 0119, types, and the full read/write chain
- Introduce WORK_TYPE_ADAPTERS registry so aggregate queries fan out per registered type (missing entry = compile error) and merge the per-type cost queries into one
- Collapse the linear/github twin modules into createSnapshotWorkRegister/createSnapshotWorkAdapter factories
- Extract shared dispatchWorkRegistrationIntent into @lobechat/builtin-tools for the server executor and client store
- Move WorkSummaryCard into src/features/Work with a WORK_TYPE_DESCRIPTORS registry; split WorksSection into VersionList + WorkVersionHistoryCard

* ⚡️ perf: skip work summary assembly on mid-stream message refetches

Every tool_end / step_complete refetch and every step_start uiMessages
snapshot re-ran the per-type Work summary queries for the whole page. Thread
a skipWorks flag through getMessages -> MessageModel.query so mid-stream
paths skip the assembly; works settle on the initial page load and the
terminal agent_runtime_end refetch. replaceMessages grafts previously
rendered works back (preserveWorks) so chips don't flicker mid-run.

* ✅ test: rename leftover work version role fields to changeType

* ♻️ refactor: slim work snapshots to display metadata and rename source to sourceToolName

* ✅ test: adapt remaining work registration tests to sourceToolName and slimmed intent

* 🔒 fix: enforce task visibility on Work reads and skip Works on share pages

* 🔒 fix: allowlist external Work URLs and align Work write permission scopes

* 🐛 fix: harden Work registration (snapshot truncation, rejected-write logging, intent stash drain)

* 🐛 fix: persist dormant gateway status from ensure reconcile

* ♻️ refactor: drop dead tool-execution context fields and fix misplaced work JSDoc

* ♻️ refactor: migrate document Work registration to manifest-driven dispatch

* ♻️ refactor: merge linear/github work types into one external type

Collapse the per-provider `linear` / `github` Work types into a single
`external` type before the registry ships, while the persisted shapes
(works.type values, snapshot jsonb keys) are still free to change:

- unified ExternalWorkVersionSnapshot / list / summary / event / register
  types; github's repo/number snapshot fields dropped (no consumers —
  identifier already encodes owner/repo#number)
- one external.ts registers/queries for both providers; skill-result
  routing becomes a SKILL_TOOL_RESULT_NORMALIZERS registry where
  `satisfies Record<WorkSkillProvider, …>` turns a whitelisted provider
  without a normalizer into a compile error
- WORK_PROVIDER_RESOURCE_TYPES is the single provider ⇄ resourceType
  source; the reverse lookup is derived, never hand-written
- provider-specific tool-result PARSING (linearToolResult / gh CLI
  tokenizer) is intentionally untouched — only its output type unifies
- WorkGallery keeps per-provider Linear/GitHub tabs, now filtering by
  provider over the unified type; SWR workspace key switches to the
  gallery filter key so the two tabs don't share a cache entry; i18n
  keys unchanged

Adding a future provider = WORK_SKILL_PROVIDERS + resource-type map +
one normalizer; no new types, DB model, or UI descriptor.

Note: dev databases seeded before this commit hold works.type =
'linear'/'github' rows that the queries no longer match; re-seed or
ignore (branch has no production data).

* ♻️ refactor: lift Work display columns onto works and drop version snapshots

Schema redesign from PR review (branch-local tables, no shipped data):

- works: progressive-disclosure display columns — title (layer 1),
  description (120-char preview, layer 2), content (full untruncated text,
  layer 3; NULL for document Works whose layer 3 is the document itself) —
  plus identifier/status/url lifted from the per-version snapshot
- work_versions.snapshot (jsonb) deleted: versions are a pure audit log;
  the works row holds the merged current display state, updated under the
  same FOR UPDATE lock as the currentVersionId bump (patchFields → column-
  granular patch; absent → full overwrite)
- sourceToolIdentifier on both tables: works keeps the creator tool
  (coalesce write-once), work_versions the per-mutation tool; threaded from
  the tool payload identifier through both transports, skills stamped
  DB-side from the provider
- id shapes: works prefix work→wk; work_versions text nanoid→uuid (and
  works.current_version_id retyped to uuid to keep the join operator valid)
- works.resource_id now nullable (NULL bypasses the partial unique indexes)
- task Works keep status NULL: the live tasks join stays authoritative

Migration 0119 regenerated in place. Dev DBs that already applied the old
0119 will journal-skip the new one — reset the dev DB (or drop
works/work_versions and re-run migrate) to pick up the new shape.

* 🔒 fix: hide private-document Works from other workspace members

workOwnership let any workspace member list a document Work even when the
backing document was private. Add documentVisibilityGuard (mirroring
taskVisibilityGuard): non-registrants must pass an EXISTS check that the
backing document is public or their own. Orphaned document Works (backing
row hard-deleted) stay visible to the registrant only.

* ♻️ refactor: cap works.content at write time and drop document title fallback

- content is capped at 65,536 chars (GitHub issue-body limit) in
  buildWorksDisplaySet — the single choke point every registration path
  writes the works row through — so an agent-generated multi-MB body can
  never land on the row that every list/summary query selects
- document Works no longer synthesize a filename fallback for a null
  title at write time: the card already falls through to the identifier
  at the call site, and the stored title should keep data gaps visible

* ✨ feat: record creation conversation provenance on works

* ♻️ refactor: rename works provenance columns to sourceTopicId/sourceThreadId

* 🐛 fix: relay work registration intents over the agent gateway

* 🐛 fix: redact skill work payloads from recorded step events

* 💄 style: mirror work file keys to en-US and fix import order

* 💚 fix: correct type errors in gateway work relay tests

* ⚡️ perf: optimize work registry queries

* ⚡️ perf: trim work card query payloads

* ♻️ refactor: centralize work registration adapters

* ♻️ refactor: store work display snapshots in versions

* ♻️ refactor: materialize current work snapshots atomically

* ♻️ refactor: require work tool identifiers

* ♻️ refactor: simplify work version agent attribution

* 🐛 fix: enforce work visibility scope

* 👷 build: regenerate work registry migration

* 🐛 fix: resolve post-rebase type conflicts

* ✅ test: add missing sourceToolIdentifier to registerClientWorkFromIntent fixture

* ♻️ refactor: replace works.rootOperationId with immutable origin provenance

works.rootOperationId was a latest-wins single-value slice of an N:M
relation: an edit from another conversation silently stole the summary
card from the conversation that created the Work. Replace it with:

- immutable origin columns (originTopicId/originThreadId/originAgentId)
  stamped once when the Work identity row is inserted, powering upcoming
  "works created in this topic / by this agent" filters
- listSummariesByRootOperations now anchors each Work to the latest
  version event WITHIN the requested operation set, so foreign edits can
  no longer unhook a conversation's summary cards

* 🔨 chore: align 0121 migration meta with canary after rebase

* 💄 style: drop the works list header row and top margin in message works

* ✨ feat: add topic-grouped works gallery to resource page
2026-07-17 16:18:07 +08:00
Arvin Xu 344d9eef4b ♻️ refactor: migrate client sub-agent transport (#17240) 2026-07-16 10:09:21 +08:00
Arvin Xu 78d41999c2 ♻️ refactor: migrate client context compression transport (#17209)
* ♻️ refactor: migrate client context compression transport

* 🐛 fix: supersede prior compression summaries
2026-07-16 01:14:03 +08:00
Arvin Xu 6c0c0a90e3 ♻️ refactor: migrate client call llm to runtime transport (#17140)
* ♻️ refactor(agent): migrate client call llm executor

* 🐛 fix(agent): preserve compressed and image-only llm results
2026-07-15 11:17:58 +08:00