* ⚡️ 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>
* ♻️ 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>
* ♻️ 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>
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>
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>
* ♻️ 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>
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>
* 🐛 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>
* 👷 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'.
* 🐛 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>
* ⚡️ 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
* 📈 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>
* ♻️ 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
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>
* 🐛 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>
* 🐛 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>
* ✨ 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