When a user-installed skill (e.g. RedisOps) was bound to an agent, the
model frequently called the skill name directly as a tool, hit
"Tool not found: RedisOps", and either gave up or fell back to shell
guessing. Two compounding causes:
1. The system prompt block injected by SkillRuntimeService listed each
skill as `- **RedisOps** — desc`, which is the same format used for
tool catalogs and primed the model to call the names directly. The
"how to use" instructions referenced `read_skill_file` /
`run_skill_script` — names that don't exist in the tool registry,
so even a compliant LLM couldn't follow them.
2. ToolExecutionExecutor's `callback == null` branches returned a bare
"Tool not found: <name>" string. The model had no recovery signal
and no hint that the name it called was actually a skill.
Fix is two-layered:
- Prompt rewrite (SkillRuntimeService.buildSkillPromptEnhancement): lead
with an explicit warning that skills are NOT directly callable, use the
correct camelCase tool names (readSkillFile / runSkillScript), include
a concrete worked example anchored to the first enabled skill, and
render the listing as a markdown table so it stops looking like a
callable tool list. listAvailableSkills tool description and output
follow the same pattern.
- Runtime safety net (ToolExecutionExecutor): when toolCallbackMap.get
misses, check if the requested name (case-insensitive) matches an
active skill. If so, return a precise hint telling the LLM the right
invocation pattern instead of the bare error. Wired through both the
main execute path and the pre-approved replay path. SkillRuntimeService
is attached via a setter from AgentGraphBuilder so the executor's many
legacy constructors stay untouched, and it's nullable so isolated
tests still work.
Adds 5 unit tests covering: skill match -> hint, case-insensitive match,
no-match -> bare error, no SkillRuntimeService wired -> bare error,
pre-approved replay path -> hint.
Reported and reproduced by @pipima9950-glitch in issue #46.
Three layers landed together because they share the same routing /
lifecycle plumbing:
1. Cron output unification
- New CronConversationResolver routes web-origin jobs to the per-workspace
tasks_<wsId> conversation; IM-bound jobs go to the channel session
conversation when one exists (matched by senderId then targetId);
legacy cron_<id> remains as the fallback.
- CronJobLifecycleService inserts a system-role header divider when a
run starts so users browsing the unified tasks_<wsId> view can tell
which job started a run. BaseAgent.sanitizeForLlm filters these
headers so they never reach the model.
- WorkspaceService seeds tasks_<wsId> on workspace creation; V65
migration backfills existing workspaces.
- DeliveryConfig gains a userId field so IM session lookup can match
by senderId (replyToken-based targetId is not stable across runs).
- ConversationVO recognizes tasks_/cron_ underscore prefix as cron
source. MessageList renders the system header as a labeled divider.
- ChatConsole pins tasks_* conversations and tracks per-conversation
read state so new cron output gets a visible unread dot.
2. Reminder task type
- New task_type='reminder' in CronJobEntity + service validation.
- CronJobRunner short-circuits 'reminder' jobs: hands trigger_message
to finishRunAndPublish verbatim, no LLM call. Fixes a regression
where reminders were rephrased into echoed wrappers.
- New create_reminder tool alongside create_cron_job, with descriptions
tightened so the model picks the right one (verbatim push vs LLM
query that needs computation).
- CronJobs.vue gets a third radio option + dedicated reminder field.
3. In-flight progress placeholder
- Cron uses non-streaming chat()/execute(); tool-heavy ReAct loops
can run 1-5 minutes between start and finish with no visible
state, looking hung.
- New GET /api/v1/cron-jobs/active-runs returns runs in status=running
for a conversation. ChatConsole polls it on the existing 4s tick
(and on conversation switch) and shows a spinner bar with elapsed
time. When run count drops to zero, it refetches messages so the
assistant bubble appears within ~1s of finish.
Add a callout above the existing intro to make the wedge explicit:
multi-user workspaces, approval-gated sensitive actions, full audit trail,
production-grade health monitoring, per-channel error isolation.
One JAR on your own machine, zero data egress.
User-reported field issues + a deeper code audit revealed multiple
overlapping bugs in the prior cron-channel delivery change. This fixes
all six.
#1 — Concurrency race on ToolExecutionExecutor (root cause of 'sometimes
succeeds, sometimes fails' tool calls). The volatile instance fields
currentRequesterId / currentWorkspaceBasePath / currentChatOrigin
were shared by every conversation routed through the same per-agent
executor; one user mid-build-loop while another's execute()
overwrote the field would cross-contaminate the captured values into
PreparedToolCall. Fix: kill the instance fields, thread
origin/requester/workspace as method params straight into
PreparedToolCall snapshot. Comment pins the rule so it cannot regress.
#2 — CHAT_ORIGIN missing from KeyStrategyFactory (latent timebomb,
masked by spring-ai-alibaba-graph-core's non-filtering builder path).
Without an addStrategy registration, multi-node state merges in long
ReAct / Plan-Execute loops drop the key, ActionNode reads
ChatOrigin.EMPTY, and the cron persists with channel_id=NULL. Also
caught 4 more keys that were latently unregistered:
WORKSPACE_BASE_PATH, STOP_REQUESTED, RETURN_DIRECT_TRIGGERED,
DIRECT_TOOL_OUTPUTS. All five now registered in both ReAct and
Plan-Execute factories.
#3 — CronJobs UI didn't surface channel binding. CronJobDTO carried
channelId / deliveryConfig but the list page never rendered them.
Added: (a) 'channel' column on list page, (b) channel + targetId
rows in the detail modal, (c) backend batch-loads channel names via
ChannelMapper.selectBatchIds so the column shows the human-readable
name, (d) i18n keys (zh + en), (e) channelName field on TS CronJob
type.
#4a — DingTalk targetId expiry. ChannelChatOriginFactory.resolveTargetId
used to prefer ChannelMessage.replyToken which for DingTalk encodes
a sessionWebhook URL that expires ~90 minutes after the inbound
message. Cron persisted with that webhook then dies with 401/403 and
marks NOT_DELIVERED forever. Fix: prefer the stable chatId, fall
back to senderId — both work indefinitely via DingTalk's Robot API.
#4b — Scheduler pool exhaustion under long LLM. CronJobService's
ThreadPoolTaskScheduler ran with poolSize=4 AND the LLM call lived
on the scheduler thread. Four concurrent crons saturated the pool
and the 5th silently missed its tick. Fix: keep scheduler tiny (it
just fires triggers) and offload runAgent to a dedicated
virtual-thread executor (cron-execute-* threads). LLM workload is
I/O-bound — virtual threads scale to thousands at trivial cost.
#5 — Minor latent bugs:
- AbstractCronResultDelivery.claimRun used .in(... 'NONE','PENDING',null),
but SQL IN never matches NULL. Rewrote as IS NULL OR IN
(NONE,PENDING) so legacy pre-V57 rows can still claim.
- CronDeliveryListener.onCompletedRaw was an empty @EventListener
with a wrong-headed comment about test fallbackExecution. Removed.
- CronJobTool.resolveAgentId silently returned 1L when origin
lacked an agentId — would silently bind to whatever agent #1
happens to be. Replaced with explicit error so wiring bugs surface
immediately instead of producing scheduled-but-never-runs crons.
State-key registration guard. New StateKeyRegistrationCoverageTest
scans MateClawStateKeys via reflection and parses
AgentGraphBuilder.java to extract every
.addStrategy(MateClawStateKeys.X, ...). Asserts every non-_NODE
constant appears in at least one factory. Caught the 4 unregistered
keys above on first run; will catch any future 'forgot to register'
regression.
Tests: 33 unit/arch tests + 27 regression in touched areas — all green.
Vue typecheck clean.
Refs: #25, #16
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.
Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
(ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
(CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
(T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
with SQL CAS idempotency on mate_cron_job_run.delivery_status —
replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
channel-bound crons to suppress hallucinated 'install CLI to send
WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
create, restore on replay so cross-restart approvals keep channel
binding; ChannelMessageRouter + ChatController web-replay both prefer
the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
CronJobMapper.selectListWithDeliveryStatus subquery.
Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).
ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
method — pins the 3-segment lifecycle rule.
Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
payload fallback + unknown-field tolerance.
Refs: #25, #16
- Generalize the OpenAI-compatible chat/models path resolver so any
baseUrl ending in /v{N} (Ark /v3, Zhipu /v4, ...) drops the duplicate
/v1 prefix. Volcano Engine test-connection and chat were posting to
/api/v3/v1/chat/completions and getting 404.
- Replace the six pre-seeded Doubao alias rows (doubao-1.5-*) with five
valid Ark direct-call ids (doubao-seed-1-8-251228 etc.) and flip
support_model_discovery=TRUE so users can refresh their account's
actual catalog. Aliases were marketing names, not API names, so every
call hit InvalidEndpointOrModel.NotFound.
- Translate Ark business errors into actionable Chinese hints: include
the response body in the error chain, match ModelNotOpen and
InvalidEndpointOrModel codes, extract the offending model id, and
classify them as MODEL_NOT_FOUND so failover skips retries.
Phase 1 of the model-module refactor: combine pool / cooldown / probe-
completion signals into a single Liveness state surfaced through the
provider DTO, so the dropdown stops listing providers that are provably
unreachable. Zero schema change; one PR backend + frontend.
Backend
- Liveness enum with five mutually-exclusive states: LIVE, COOLDOWN,
REMOVED, UNPROBED, UNCONFIGURED. Computed in ModelProviderService
from AvailableProviderPool / ProviderHealthTracker / ProviderInitProbe
snapshots batched once per listProviders() call.
- ProviderInitProbe.hasBeenProbed exposes a monotonic Set so the UI
can distinguish 'still booting' from 'probed and removed' — without
it the startup window flashes false REMOVED states.
- ProviderInfoDTO gains liveness + unavailableReason +
cooldownRemainingMs + lastProbedAtMs. The legacy 'available' boolean
stays but is now derived from liveness == LIVE so the chat fallback
walker and the dropdown agree about what's usable.
- ProviderInitProbe injected into ModelProviderService via
ObjectProvider to break the startup cycle (probe already depends on
the service).
Frontend
- ProviderInfo type extended with liveness + the three detail fields.
- ModelSelector filters UNCONFIGURED + REMOVED out of the dropdown,
shows COOLDOWN / UNPROBED with a status dot and dimmed rows that the
user can still click to override.
- ProviderCard renders a five-state badge driven by liveness instead
of the old configured + pool-entry combo. Reprobe button now keys
off liveness in {REMOVED, COOLDOWN}.
- useProviders drops loadProviderPool / providerPool — pool data ships
inline on each ProviderInfo, saves a round trip per page load and
keeps a single source of truth.
- i18n: 8 new keys across zh-CN and en-US for liveness labels and the
cooldown countdown tooltips.
Bonus fix (discovered during verification): AgentGraphBuilder.buildOpenAiApi
hard-required a usable API key on every OpenAI-compat provider, ignoring
the per-provider requireApiKey flag. That bug stranded keyless local
runtimes (LM Studio / MLX / llama.cpp) the moment a user actually
launched them; Ollama only worked by accident because its seed row
carries a placeholder string in api_key. keyRequired now honors
requireApiKey, and Spring AI's NoopApiKey is used when no key is needed
so the Authorization header is omitted entirely.
Test
- ModelProviderServiceLivenessTest covers all five Liveness states +
the probe-bean-absent fallback branch.
- vip.mate.llm.** suite (118 tests) green; vue-tsc clean.
- End-to-end browser sanity: 27 raw providers reduce to 6 LIVE groups
in the chat dropdown; LM Studio / MLX / llama.cpp render REMOVED red
badges with reprobe buttons; cloud providers without keys show
UNCONFIGURED.
Issue #24: tools selected in the agent binding UI had no effect at runtime.
mate_tool.name stores the Java class name (e.g. "BrowserUseTool") and was
written into mate_agent_tool.tool_name, but AgentToolSet.withAllowedToolsOnly
matched by the @Tool function name (e.g. "browser_use") — so every binding
was silently filtered out.
Fix: AgentToolSet builds an alias index per ToolCallback indexed by every
equivalent identifier — function name, Spring bean name, and Java class
simple name. withAllowedToolsOnly / withDeniedToolsFiltered / excluding
all accept any of these aliases, mirroring how Spring's BeanFactory accepts
bean names + aliases.
ToolRegistry.getEnabledToolSet now threads a bean→beanName resolver into
the new AgentToolSet.fromCallbacks(...) overload. Existing two-arg callers
keep working; tests pass without changes.
Zero data migration: stale mate_agent_tool rows that previously had no
effect now resolve correctly via the class-name alias.
Some self-hosted OpenAI-compatible serving frameworks return a 400 Bad Request
with a generic Pydantic "body=None / Field required" error when the outbound
request carries tool_choice="auto" but the server was launched without an
auto-tool-choice opt-in flag. The error message hides the real cause: the
request is rejected at validation time before the body is parsed, so the
upstream client sees only the generic body-missing error.
Per the OpenAI spec, omitting tool_choice when tools is non-empty is
functionally equivalent to "auto" — the server defaults to auto-pick.
Adding a stripAutoToolChoice patcher to the buildOpenAiApi chain:
- changes nothing on compliant servers (OpenAI / DashScope / DeepSeek / Kimi
default to auto when tools are present)
- unblocks strict OpenAI-compatible self-hosted endpoints
Explicit values other than "auto" ({"none", "required", or a function
descriptor}) are passed through unchanged.
Run on both chatCompletionEntity and chatCompletionStream paths so both
buffered and streaming calls benefit.
The two tools-sync scripts ran on every startup and used H2 MERGE INTO
... KEY(id), which overwrites every column on existing rows. That
silently reverted UI-toggled `enabled` and was the proximate cause of
a recent WriteFileTool/EditFileTool outage.
They were also a strict subset of the fresh-install seed (data-zh.sql /
data-en.sql register all 19 builtins; the sync scripts only 16) and out
of date. Per-tool Flyway migrations (V3, V31) are already the canonical
'register a new builtin' path, so the sync layer was duplicated and
error-prone.
Delete both files and the runToolSyncScript() loader. Tool descriptions
shown to the LLM come from @Tool annotations in code, not the DB row,
so removing per-startup metadata refresh has no functional impact.
Three small but high-impact fixes that all surfaced together while
verifying the long-form generation flow.
1. ChatConsole onBeforeUnmount no longer kills the backend turn.
Previously, switching tabs / route navigation / any cause that
unmounted the chat view called stopChatGeneration(), which POSTs
/chat/{cid}/stop and aborts the in-flight LLM call. The user
reported a turn dying mid-generation just from switching pages.
Replaced with resetForNewConversation() — front-end SSE disconnect
only, no /stop. Backend keeps running; pollActivity / status probe
reconnects on return. Aligns with the existing comment in
selectConversation: "let A's backend agent run continue running."
2. Agent max_iterations raised 25 → 100 with a hard ceiling.
The previous 25-step ceiling caused LimitExceededNode to fire on
substantive multi-tool tasks (document generation + image conversion
+ retry loops). 100 matches QwenPaw's _MAX_MAX_ITERATIONS upper
bound. New plumbing:
- BaseAgent.MAX_ITERATIONS_HARD_CEILING = 100 public constant
- BaseAgent default field 25 → 100 (Java-side fallback)
- AgentGraphBuilder clamps any per-agent DB override to the
ceiling at runtime; if the row holds 200, runtime sees 100 and
a WARN is logged with the original value.
- V47 migration (h2 + mysql) idempotently bumps the three default
seeded agents (1000000001, 1000000002, 1000000003) only if they
still hold the old defaults (25 / 20). User-customized values
are not touched.
- data-en/zh/-mysql-en/-mysql-zh seed files updated to 100 for
fresh installs.
3. DocxRenderTool tells the LLM not to prepend a host to the URL.
DeepSeek and Claude have both been observed wrapping the
/api/v1/files/generated/{id} relative path returned by renderDocx
into an absolute URL with a hallucinated domain (e.g.
https://ai-tools-system.com/...), breaking the download link in
the rendered chat bubble. The tool's return string now appends an
explicit "must use the relative path verbatim, do not add any
https:// or http:// prefix" instruction, which Claude and
DeepSeek both honor.
A bundle of stability fixes that all surfaced together while running
the same long-form generation task across multiple turns. Each one
addresses a distinct way the previous behavior silently dropped
content the user had already seen on screen.
1. Mid-turn narrative persistence (StateGraphReActAgent +
SummarizingNode). Intermediate ReasoningNode rounds and
SummarizingNode broadcast their content_delta directly to the
SSE channel for live display, but the StreamAccumulator only
received the final answer. After refresh the assistant message
showed only tool_call cards with no body text.
StateGraphReActAgent now also forwards STREAMED_CONTENT (already
set per round) as a persistOnly StreamDelta whenever it changes,
so every narrative chunk lands in the accumulator's content
buffer and gets written to mate_message. SummarizingNode now
writes its summary into the same key so summarize narratives
persist too.
2. Follow-up message queue, not dispose (ChatController#interruptStream).
Sending a new message while a turn was running called
requestInterrupt, which dispose()d the active Reactor chain mid
LLM call. That cancelled the in-flight generation, lost partial
tokens, and left the user staring at a half-finished bubble.
The endpoint now uses enqueueMessage in all paths, matching
the "wait for current turn, then run" behavior. The old
requestInterrupt API is kept for any future force-replace UI
but no caller routes to it.
3. Queued user message ordering (ChatStreamTracker.QueuedInput +
ChatController.startQueuedMessage). interruptStream used to save
the queued user message immediately, before the in-flight
assistant message finalized in doOnError. listMessages orders
by create_time ASC, so the queued user message ended up above
the assistant reply it was supposed to follow. QueuedInput now
carries contentParts; persistence is delayed to startQueuedMessage,
which runs only after Asst-N is on disk.
4. JVM shutdown flush (ChatStreamTracker @PreDestroy +
emergencySaveAccumulator). A mvn spring-boot:run restart used to
wipe in-flight turns: SSE emitter timed out, ShutdownHook fired,
HikariPool closed before doOnError could save. ChatStreamTracker
now exposes an emergency-save callback per RunState; ChatController
registers one per stream that snapshots the accumulator and
writes status="interrupted_shutdown". @PreDestroy walks active
runs, invokes the callback, then disposes. Spring's reverse-order
bean teardown keeps ConversationService and Hikari alive long
enough for the save to complete.
5. Observation thresholds for summarize (GraphObservationProperties +
application.yml). The previous total-chars threshold of 12 KB
triggered summarize after one or two RFC reads, costing a 40 to
80 second compaction LLM call per loop. Tuned to: total 200 KB,
single 16 KB, large-result 32 KB, rounds safety net 25. Java
field defaults reverted to the conservative original values so
application.yml stays the source of truth.
6. Frontend thinking segmentation (useChat.ts thinking_delta +
phase). Multi-round ReAct turns merged every reasoning + summarize
round's thinking into one segment, accumulating to 9 KB+ in a
single bubble. thinking_delta now uses findLast(running) so a
tool_call_started or phase transition closes the previous segment
and the next delta opens a fresh one. phase event also closes
running thinking/content segments.
7. Other small things bundled: removed a debug metadata-keys log
that flooded the log file with one line per stream chunk; fixed
three stale tests that didn't compile after earlier constructor
changes (WikiLogServiceTest, WikiOverviewSpliceTest,
WikiProcessingServiceLazyTest); added rfc-066 documenting the
unified message queue + priority refactor as the next logical
step on top of these stabilizations.
Verified end-to-end with multiple full sessions: a four-minute
generation that produced the expected docx and a follow-up enqueue
that ran cleanly after the previous turn naturally completed,
without the old "Disposable unavailable" interrupt path.
A series of cross-cutting stability fixes that surfaced together
during a long debugging session.
reasoning_content / Claude prefill self-replicating 400:
- ChatController persists typed errors (content starts with '[错误] ')
with status='error', so the failure text stops being re-sent as
multi-turn context — DeepSeek thinking 400 ('reasoning_content
must be passed back') and Claude 400 ('does not support assistant
message prefill') used to recursively re-create themselves every
retry by polluting history.
- BaseAgent.sanitizeForLlm filters status='error' / '[错误] ' prefix
assistant messages from history before LLM dispatch.
- BaseAgent.fetchHistoryMessages defensively drops trailing
AssistantMessages — Claude rejects assistant-tail prompts.
- NodeStreamingChatHelper.dropTrailingAssistant runs the same
defense at every doStreamCall pre-egress, so the in-turn
summarizing→reasoning transition (which leaves an assistant
scaffold at the tail) doesn't trip Claude either.
- AgentGraphBuilder.FallbackPolicy.DEEPSEEK switched (null,true,true)
→ (' ',false,true), aligning with KIMI/OPENAI's tolerant ' '
fallback. The previous 'force explicit 400' design was the
self-replicating loop's prime mover.
narration + tool args truncation:
- ReasoningNode.DEFAULT_MAX_OUTPUT_TOKENS 4096 → 16384. The 4k cap
was decapitating renderDocx tool_call args mid-stream when the
model emitted a long content field on top of thinking content;
the resulting 'invalid JSON' aborted execution silently.
- ReasoningNode appends a hermes-style TOOL_USE_ENFORCEMENT clause
to every system prompt: 'when you say you will perform an action,
call the tool now in the same response — narration is a protocol
violation'. Treats 'now I will generate the docx' (and never
actually calling renderDocx) as a forbidden pattern.
- ToolExecutionExecutor.normalizeToolExecutionError reframes the
JSON-truncated error as actionable instructions: 're-call the
same tool now with shorter content or split into multiple
sequential calls; do NOT describe the result as text'.
side fixes from the same evening:
- ChatController doOnComplete skips completionPublisher.publish
when isError=true, keeping memory extraction off the garbage path.
- ChatController doOnComplete queued-message guard simplified to
'cr.queuedInput() != null', matching the other 4 sites in the
controller. The previous 'isInterruptFollowup || !wasStopped'
guard silently dropped queued messages when the user did
Stop-then-Enqueue (wasStopped=true && interruptType=null), losing
the freshly-typed follow-up message.
- prompts/graph/summarize-system.txt now distinguishes 'single
task' (default; output one cohesive summary) from 'multiple
independent sub-tasks' (use the子任务 N format). Stops the
summarizer from inventing '子任务 1: PRO-027' decomposition for
unitary requests like 'write me a project proposal'.
Wire memory-facing events (turn-started, turn-completed, session-ended,
memory-written) through a single MemoryLifecycleMediator so
MemoryProvider implementations can hook into the agent conversational
flow without spreading side-effects across the runtime.
Ten atomic steps shipped under feat/dream-v2-p1-lifecycle:
- A.1 + A.2: MemoryLifecycleMediator class + TurnContext value object
- A.3: TurnStartedEvent / TurnCompletedEvent domain events
- A.4: MemoryLifecycleEventListener bean for Spring event plumbing
- A.5: MemoryProvider.onMemoryWrite default method (backward compatible)
- A.7: wire the mediator into AgentService at the right hook points
- A.8: LifecycleFlagGuardTest — feature flag must gate every hook
- A.9: MemoryLifecycleMediatorTest — unit coverage per hook
- A.10: LifecycleRecallCountIT — F4 regression across the stack
Feature flags (all default OFF; enable per phase after staging):
- mate.memory.lifecycle-mediator-enabled
- mate.memory.dream.focused-enabled
- mate.memory.dream.archive-enabled
This is Phase 1 foundation only — focused-dream and archive-dream
providers arrive in later phases.
Two related changes that align buildFallbackChain with how users actually
think about failover.
1) Source = configured providers (was: only providers with fallback_priority > 0)
Earlier the chain was strictly "providers the user explicitly opted in via
fallback_priority > 0". A healthy in-pool provider with priority=0 was
silently excluded — surprising since the pool was supposed to be the source
of truth for "what is usable". After this change:
- Candidates = every configured provider
- Pool gating = same as before (in-pool members only at build time;
runtime walker re-checks)
- Order = agent prefs (PR-3) → fallback_priority asc (>0) →
priority==0 alphabetical
So fallback_priority is now purely an ordering hint, never an exclusion.
2) Per-provider model picker = default OR first-enabled (was: default only)
Previously a provider was skipped if no chat model on it had is_default=true.
That is admin friction with no benefit — every provider had to be visited in
Settings just to mark a default before it could appear in failover. New
pickFallbackModel():
- first try getDefaultModelByProvider — user explicit pick wins
- otherwise take the first enabled chat model on the provider
- skip only if neither exists
User-visible effect on the deployment that surfaced this:
- kimi-code primary fails (401 — real auth issue, separate from this bug)
- Pool short-circuits primary → walker fires
- Walker now sees dashscope (in-pool) AND ollama (in-pool) as candidates,
even though neither has fallback_priority set
- dashscope first enabled qwen model is picked → request succeeds via
dashscope without anyone touching Settings
45 failover-related tests still green (unit-level chain-build behavior is
backward-compatible; only the candidate set and model-selection lookups
changed, both broadening the chain rather than narrowing it).
PR-0 only installed the strategy seam; the actual ~600 LOC of provider-
specific construction stayed in AgentGraphBuilder as transitional public
helpers. PR-0b moves the DashScope + Anthropic halves into their builders
proper. (OpenAI larger refactor — 5 sub-helpers including Kimi/o-series
special cases — is left for a follow-up PR-0c.)
AgentDashScopeChatModelBuilder now owns:
- buildDashScopeApi (with provider/env/reflection key+url fallback chain)
- buildDashScopeOptions (model/temp/max-tokens/topP + built-in search)
- normalizeDashScopeBaseUrl (strip /compatible-mode/, return null for SDK default)
- readApiKeyFromDefaultChatModel + readBaseUrlFromDefaultChatModel +
readDashScopeApiFromDefaultChatModel (reflection-based final fallback)
- isBuiltinSearchEnabled (renamed from isDashScopeSearchEnabled, called
by AgentGraphBuilder.build via the now-injected dashScopeBuilder ref)
AgentAnthropicChatModelBuilder now owns:
- buildAnthropicApi (key validation, applyHttpTimeouts duplicated locally)
- buildAnthropicOptions (extended-thinking budget mapping low/medium/high/max
→ 4k/8k/16k/32k, temperature=1 enforcement, RFC-014 prompt cache options)
AgentGraphBuilder dropped:
- DashScope: ~120 LOC (api + options + 4 helpers + isDashScopeSearchEnabled)
- Anthropic: ~75 LOC (api + options)
- DashScopeChatModel + DashScopeConnectionProperties fields (unused after move)
- Deprecated single-fallback buildFallbackModel (no callers, superseded
by buildFallbackChain since RFC-009 PR-1)
- 5 imports for moved DashScope/Anthropic types
Net: -154 LOC in AgentGraphBuilder (1721 → 1567), +372 across the two new
builders. Strategy seam is now real for 3 of 4 protocols (ChatGPT was
already standalone, OpenAI is PR-0c). 220/220 tests still green — no
behavior change.
Two related issues from the Kimi-401 user report:
1. Backend (NodeStreamingChatHelper): a primary AUTH_ERROR (e.g. Kimi 401
with an invalid API key) returned immediately without trying the
fallback chain — a fallback provider with a different, valid key
never got a chance. Even with DashScope correctly configured as the
fallback, the user chat dead-ended on a 401.
The original assumption ("auth never self-heals so do not retry")
holds for the primary same-model retry loop but is wrong for the
fallback chain — different providers have different keys. Apply the
same break-into-fallback policy that BILLING and MODEL_NOT_FOUND
already use. recordPrimary(false) is preserved so the cooldown
counter still accumulates.
2. Frontend (chatError.ts + i18n): the error-text matching for
/认证|auth|unauthorized|401/i was so broad it matched the substring
"auth" inside URLs like https://api.kimi.com/.../auth, classifying
any model 401 as user "session expired" and rendering the misleading
"页面将自动跳转到登录页" copy. (The redirect itself only fires from
/api/v1/auth/* axios paths and SSE-connection 401s, not from this
payload-text path — but the copy alone is the worst kind of false
alarm.)
Add a new ChatErrorCategory provider_auth_error and split the
pattern matching: narrow auth_expired (HTTP 401 / 登录已过期 /
session expired / 凭证失效) is matched FIRST, then the broad
401-ish pattern routes to provider_auth_error. BACKEND_ERROR_TYPE_MAP
for AUTH_ERROR is also remapped, since structured backend payloads
currently always come from LLM providers — never from our own
/api/v1/auth path.
Tests
- NodeStreamingChatHelperFailoverTest (5 cases): primary 401 →
fallback succeeds; chain skips auth-failing fallback to next healthy
one; whole-chain failure surfaces last AUTH_ERROR (no silent drop);
BILLING regression unchanged; primary-success path does not touch
chain
- Browser preview verified: new i18n keys resolve in en-US, classifier
correctly routes "[错误] 401 from kimi.com" → provider_auth_error
while "[错误] HTTP 401 from /api/v1/auth/ping" stays auth_expired
- 186 tests pass (was 181 + 5 new); vue-tsc clean
Do-not-touch list: handleAuthFailure() in useStream/api/index.ts (real
session-expiry path) is unmodified — only the misclassification
upstream is fixed. auth_expired i18n copy is unchanged.