Keep autonomous prompts separate from persisted user messages while retaining
conversation history and queued user attachment routing. Carry verified
checklist evidence across segments and evaluate only changed criteria.
Cancel model streams without interrupting checkpoint database writes. Fence
late worker admission during shutdown, persist accepted queued messages and
attachments, and leave interrupted execution leases recoverable.
Add regressions for prompt selection, cumulative evidence, cancellation I/O,
queued input durability and shutdown recovery. Verify 250 focused tests,
200 real General Assistant conversation rounds, 12 checkpoints across 13
autonomous segments, and pause/resume/disconnect/restart boundaries.
- Add a database-backed supervisor with durable scheduling, fenced leases,
cooldowns, bounded worker concurrency and expired-lease restart recovery.
- Default new goals to persistent execution with zero meaning unlimited
cumulative budget; preserve legacy goals and explicit positive limits.
- Yield bounded graph segments to the supervisor instead of ending unfinished
goals at graph-local continuation limits. Require persisted checklist
evidence before accepting completion, including concurrent criterion edits.
- Share conversation admission across interactive and background execution;
preserve partial replies, usage and queued user input during interruption.
- Persist Stop and missing-input pauses, respect approval boundaries, and
commit resume and approval-denial transitions with correct transactions.
- Retry identifiable transient failures with backoff; retain visible pauses
for budget limits and errors that require review instead of replaying tools.
- Expose owner-authorized execution status and reconnectable scheduling events;
add H2, MySQL and Kingbase migrations, API types and bilingual documentation.
Validation: 298 focused backend tests passed, including persistence, restart
scheduling, approval races, cancellation, admission and existing runtime tests.
Frontend type checking, bundled-doc parity and ID precision checks passed.
V188 is registered and all three dialects have unique migration versions;
the migration-map audit still reports 95 pre-existing missing registrations.
Scope: single-backend native runtime. Recovery checks existing state before
repeating effects; this does not promise exactly-once external tool execution.
Add an A2A JSON-RPC endpoint with authenticated message/send, message/stream, tasks/get, and tasks/cancel handling. Expose anonymous minimal Agent Cards while keeping the enabled-agent skills list behind existing Bearer authentication.
Bridge inbound calls into the existing agent runtime, add an in-memory task store with duplicate task rejection, JSON-RPC idempotency snapshots, terminal TTL cleanup, and SSE status/artifact event streaming with heartbeat comments.
Add the call_a2a_agent tool and peer adapter with Agent Card discovery fallback, blocking task polling through tasks/get, event-boundary SSE parsing, response caps, timeout limits, redirect refusal, and private-network SSRF protection.
Wire mateclaw.a2a configuration, document deployment settings in English and Chinese, mirror bundled docs, and cover task storage, JSON-RPC validation, card privacy, lifecycle/cancel behavior, SSE parsing, and outbound guardrails with focused tests.
Stopping a conversation previously disposed the outer reactive stream without reliably cancelling synchronous tool callbacks running on worker threads. Shell commands, skill scripts, and Playwright sessions could therefore outlive the visible chat turn.
Propagate cancellation through run-scoped hooks, interrupt active tool execution and parallel batches, terminate subprocess trees, close per-conversation browser sessions, and wait briefly for final stream persistence before acknowledging Stop.
Expose an explicit interrupting state in the chat input to block duplicate stop clicks and show progress, with regression coverage for cancelling an in-flight synchronous tool callback.
Forward unrecognized OpenAI-compatible generateKwargs keys through extraBody while reserving documented provider control keys such as modelsPath.\n\nVerification:\n- cd mateclaw-server && mvn -pl . test -Dtest=OpenAiCompatibleChatModelBuilderTest,ModelDiscoveryServiceTestPromptTest\n- cd mateclaw-ui && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit
Normalize stream idle timeout overrides, make WebChat orphan cleanup use exact run handles so stale callbacks cannot mutate replacement runs, and preserve approval replay usage metadata with regression coverage.
Raise WebChat SSE timeout and non-benign error lifecycle logs to INFO while keeping routine client disconnects at DEBUG, preserving detach-based subscriber lifecycle semantics.
Use detach rather than complete for WebChat SSE disconnect callbacks, add an orphan-run grace policy for subscriberless active runs, and emergency-save partial assistant output when reclaimed.
Close WebChat subscriber SSE connections when the logical stream reaches done/error, make the emitter timeout configurable, and ensure stale-run eviction closes subscribers instead of leaving clients waiting.
Apply a Reactor inter-frame idle timeout at the streaming chat chokepoint so half-open provider body streams surface through the normal retry/failover path. Also keep the HTTP timeout documentation accurate and cover the behavior with focused tests.
The Dockerfile pre-copies module POMs for layer caching, but the list had
not been updated when mateclaw-plugin-mem0 was added to the root POM's
<modules>. Maven fails while constructing the reactor if a declared module
directory is missing, so `mvn -pl mateclaw-server -am dependency:go-offline`
aborted with "Child module /build/mateclaw-plugin-mem0 does not exist"
before it ever reached dependency resolution — every container build broke.
Copy the missing POM and note that this list must mirror the root POM's
<modules>, even for modules the image never builds. The module only makes
the reactor readable; it stays out of the `-pl mateclaw-server -am` build,
so the image is unchanged in size.
Fixes#566
load_skill returns SKILL.md in full by design — it is the model's behavioral
contract, and pagination by default would let the model silently miss later
mandatory sections. read_file / readSkillFile / load_skill are therefore on
the spill-exclusion list so their output is never replaced by a disk pointer.
The exclusion only covered half the path. In spillRawOrTruncate, an excluded
tool's result came back from persistIfOversized unchanged (no spill), failed
the SPILL_MARKER_PREFIX check, and fell through to truncateToolResult(8000) —
so an 8261-char SKILL.md was hard-cut through the middle and stamped with a
'[TRUNCATED: ... middle omitted]' marker. Weaker models ignore the attached
fidelity note and fabricate the removed span, inventing tool calls against
endpoints the skill never described.
- spillRawOrTruncate now returns retrieval-excluded results raw; the per-turn
aggregate budget stays the backstop.
- Outsized SKILL.md degrades to resumable pagination instead of an unbounded
inline dump. Never a lossy middle-cut.
Extend the plugin memory SPI with a three-arg prefetch(agentId, userQuery, ownerKey) default method and forward ownerKey through PluginMemoryBridge, enabling per-owner isolated recall for external providers. Ship mateclaw-plugin-mem0: an optional, zero-extra-dependency plugin that bridges a self-hosted Mem0 service (semantic recall via /memories/search/, async turn sync via /memories/) with full fault isolation — not part of the default stack. Includes 42 tests and bilingual user docs.
The skill detail drawer could only show and edit SKILL.md; the bundle
files under scripts/ and references/ had no console surface, and
templates/ was readable by agents but absent from the canonical
store's bucket set.
- admin endpoints on /api/v1/skills/{id}/files: list (self-heals an
empty canonical store from on-disk files), read, upsert, delete.
Writes update the canonical row, materialize the workspace cache,
and re-resolve the skill so agents pick changes up immediately.
Path envelope enforces the three buckets and blocks traversal;
builtin skill files stay read-only; virtual skills own no files.
- templates/ becomes a first-class DB-persisted bucket shared across
the syncer, the workspace write/delete envelope, and prune guards.
- the agent-facing write_file action now mirrors into the canonical
store and re-resolves instead of writing only the local filesystem.
- SkillMarket detail drawer gains a Files tab: grouped list, viewer,
inline editor, create and delete, refetched on every entry.
Skills backed by a network service could show ready while the service
was unreachable from the current deployment (intranet-only address,
wrong network segment) — the failure only surfaced mid-task.
- endpoint requirement type: TCP-connect probe (1.5s timeout) of the
declared service address; accepts http(s)://host[:port][/path],
host:port, and bare-host forms
- URL-shaped check targets infer the endpoint type without an explicit
declaration; unparseable targets report UNKNOWN instead of missing
- probe results cached 60s per host:port so refresh passes stay cheap
and a VPN connect is picked up within a minute
- unreachable endpoints surface as setup-needed on the skill card,
pre-flight requirement rows, and the agent-facing catalog
The runtime resolved SKILL.md from the workspace directory while the
admin console read the skill_content column, so out-of-band file edits
(agent shell tools in a chat session) changed runtime behavior but never
showed up in the console, and a failed workspace export left agents
executing stale content the console claimed was current.
- SkillContentReconciler: three-way sync between the canonical DB column
and the convention-workspace file, anchored on a sidecar hash marker.
File-side edits ingest into the DB, DB-side edits materialize to the
file, two-sided conflicts resolve DB-wins with a backup.
- Skill detail GET performs a read-time reconcile and triggers a
single-skill rescan when the file side changed, so a console query is
always current without waiting for the runtime cache TTL.
- SkillMarket detail drawer refetches the row and runtime status on open
instead of rendering the page-load list snapshot.
The documented setup flow creates a real .env at the repo root (cp .env.example .env) but only deploy/.env was ignored, so root-level secrets could be committed by accident. Replace the single-path rule with .env / .env.local / .env.*.local at any level; tracked .env.example templates are unaffected.
Bundled skill scripts/ and references/ are now persisted to mate_skill_file during startup sync; a workspace missing its scripts directory is force-restored from the classpath bundle even when the SKILL.md version is unchanged; and builtin skills with neither DB rows nor on-disk files backfill from the classpath. Fixes installs performed from builds whose jar shipped without bundle scripts.
Windows checkouts with core.autocrlf=true (the Git for Windows default)
converted docker/postgres/init/10-app-role.sh to CRLF, so the container
entrypoint failed with 'cannot execute: required file not found' and the
postgres/server containers crash-looped. Pinning *.sh/*.sql to LF makes
Windows clones produce container-executable scripts regardless of the
local autocrlf setting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Send agent-generated files as native WeChat attachments via the iLink upload flow, and fix the wire protocol for file uploads: dedicated wire ObjectMapper (bypasses the global Long-to-String serializer), md5/len fields and encrypt_type on media items, channel_version 1.0.2, and explicit business-error handling on ret != 0. The weixin adapter now routes generated-file URLs through GeneratedFileScrubber, matching WeCom/Feishu behavior.
Fixes#307
The chat page is kept alive by the router so navigating away and back
only fires an activation hook, not a fresh mount. An agent created,
edited, or deleted elsewhere (e.g. the employee management page) never
reached the chat page's own agent list, and stayed invisible or
unselectable in the agent picker until a full page reload forced a
fresh mount. The agent list is now refetched on every reactivation.
Entity extraction previously constrained entity types but let the
model freely invent any relation between entities, producing noise
that diluted the entities a knowledge base actually cares about.
Adds an optional per-KB relation schema (subjectType/predicate/
objectType triples): when set, the extraction prompt is scoped to
only those relations, and a hard filter drops anything that slips
through before it is persisted. Empty/unset keeps the existing
open-vocabulary behaviour.
Windows-authored zips often store entry names in the local codepage (GBK)
without setting the ZIP UTF-8 flag, while file content stays UTF-8. The
previous fallback decoded the whole archive with one charset, so a single
GBK-named entry forced already-correct UTF-8 content to be re-decoded as
GBK, corrupting valid Chinese text into mojibake. Names and content now
each try UTF-8 first and fall back to GBK independently, per entry.
Global transformation templates (workspace_id IS NULL, e.g. the 7 built-in
starter packs made global by V165) were shared across every workspace but
not actually read-only: any workspace member could edit or delete them,
mutating/affecting all workspaces, with deletes unrecoverable (Flyway
seed runs once).
- Controller: reject update/delete of null-workspace templates with 403
(err.wiki.global_template_readonly); read/apply paths unchanged.
- Service: defense-in-depth — update/delete also reject global templates,
guarding non-HTTP callers (WikiTool LLM entry points). delete() now
checks the entity before deleting instead of deleting blindly.
- findByName: add deterministic ORDER BY (workspace_id IS NULL) ASC so a
workspace-local template wins over a same-named global one (was LIMIT 1
with no ordering). Consistent across H2/MySQL/Kingbase.
- i18n: new err.wiki.global_template_readonly (zh + en).
- Tests: +2 controller mock tests (403 on update/delete, no service write),
+2 E2E tests (global template stays intact; findByName prefers local).
Tests: 10/10 green (4 controller + 6 E2E).
In the "Edit Agent → Preferred Providers" tab the same (provider, model)
combination could be selected repeatedly — e.g. two rows of the same
provider both pointing at the same model, or two "provider default" rows.
This is unintended: a provider may repeat across the fallback chain, but
each (provider, model) should stay unique.
Root cause: addProviderEntry() pushed unconditionally (the code comment
even said "we never dedup here") and the model <option>s had no disabled
state, so already-chosen models remained selectable.
Fix (Agents.vue):
- isProviderChoiceTaken(): detect whether a (provider, model) slot — or the
provider-default slot (modelId === null) — is already used in another row.
- Model <option> + the default-model option are :disabled when already taken;
the current row's own value stays selectable (exceptIdx).
- addProviderEntry(): take the default slot if free, else the first unused
model; do nothing if every option is taken.
- The "+ Provider" pool button is disabled once the provider has no free
(provider, model) slot left, so the click is never a silent no-op.
The existing unique index uk_agent_provider_model(agent_id, provider_id,
model_id) already guards non-null duplicates at the DB level, but it cannot
catch model_id IS NULL rows (SQL treats NULLs as distinct); the UI is now
the single source of truth for that.
Fixes#530
A tool/MCP call could render 2+ times in the timeline (issue #521). The
tool is invoked once — this is a display artifact. The segment de-dup in
MessageBubble keyed on `toolName::toolArgs`, which fails two ways:
- The same logical call rendered on both the live SSE stream and the
reloaded/persisted path can carry differing toolArgs strings
(whitespace / key-order from re-serialization), so the two are NOT
de-duplicated and both survive → the reported duplicate.
- Genuine repeated calls of the same tool with identical args (e.g. shell
/ python retries) share the key and get wrongly collapsed to one.
Prefer the LLM-provided toolCallId (carried end-to-end on both live and
persisted segments, stable across serialization) and fall back to
toolName::toolArgs only for legacy segments without an id. This fixes
both the visible duplication and the over-collapse.
Adds pure-function tests for the de-dup logic.
buildWikiContext enumerated an agent's bound knowledge-base pages into
the system prompt capped only by maxContextChars (default 10000, sized
for large cloud models). On a small-context model a large KB therefore
consumed a big fixed slice of the window on every turn — the "tool token
estimate fills the context" report in #521 (the growth lands in the
system-prompt bucket, not the tool-schema bucket; wiki tool schemas are
fixed-size and do not scale with file count).
Add a budgeted buildWikiContext(agentId, budgetTokens) overload mirroring
buildRelevantContext: the page enumeration also stops once the estimated
token total exceeds the budget, appending the existing
'... and more (use wiki_list_pages)' hint. AgentGraphBuilder passes the
same prefix budget it already applies to the memory block; the legacy
Integer.MAX_VALUE path keeps chars-only behavior for large models.
Tests cover null-budget (all pages), token-budget truncation, and
zero-budget skip.
The web ChatConsole seeded a fresh conversation's model from the global
default, never the selected agent's model override, and handleSendMessage
then pinned that default onto the conversation row. Since a conversation
pin outranks the agent override in the backend runtime resolver
(AgentGraphBuilder.resolveRuntimeBaseModel), the model chosen on the
agent edit page was silently clobbered. IM channels and external webchat
were unaffected — they leave the conversation unpinned.
- applyConversationModel now follows the backend precedence:
conversation pin > agent model override > global default.
- The agent tier resolves via /agents/{id}/capabilities (authoritative)
with a synchronous fallback (currentAgent.modelName against the
enabled-model list) so an agent switch, a capability-fetch failure, or
a not-yet-hydrated deep-link still honour the override instead of
dropping to the global default.
- userPickedModel guards the async re-seed from clobbering an explicit
pick; reset on new/switch/delete conversation.
- Unit tests cover the precedence tiers, the empty-string capabilities
wire shape, and the synchronous fallback.
Custom (user-added) providers were hard-coded supportModelDiscovery=false in
createCustomProvider, so self-hosted OpenAI-compatible endpoints (vLLM /
Xinference / LocalAI / gateways) never surfaced the 'discover models' button —
users had to add every model id by hand.
- ModelProtocol: add per-protocol supportsSelfConfiguredDiscovery() + resolve()
helper (single source of truth for chat-model class and capability flags).
baseUrl+apiKey protocols (openai-compatible, dashscope-native, gemini-native,
anthropic-messages) => true; OAuth protocols => false. The flag is deliberately
narrower than 'can ever discover' (built-in ChatGPT-OAuth still discovers via
its OAuth session); javadoc warns against reusing it to gate the button.
- createCustomProvider: default supportModelDiscovery from the resolved protocol
instead of always false. Existing rows are unaffected (no migration).
- OpenAiModelsPath: new single source of truth for the models-listing path,
honoring an optional 'modelsPath' generateKwargs override (mirrors the existing
'completionsPath' override) for endpoints behind a reverse proxy / non-standard
prefix (e.g. /openai/v1/models) that would otherwise 404 on /v1/models.
Shared by BOTH discovery (ModelDiscoveryService) and the failover liveness
probe (OpenAiCompatibleListModelsProbe) so an override can't make a provider
discoverable yet still marked unhealthy by a probe hitting the wrong path.
- Tests: ModelProtocolTest (capability table + resolve fallback), OpenAiModelsPathTest
(path branch table + vendor cases + modelsPath override), and custom-provider
discovery-default assertions. Path-resolution coverage consolidated into
OpenAiModelsPathTest (was split across the discovery + probe test files).
- Docs: zh/en models.md note custom-provider discovery + modelsPath override.
Refs matevip/mateclaw#519
Give raw-material uploads a dedicated five-minute timeout and process file-picker and drag/drop uploads through a shared two-worker queue, so constrained uplinks no longer abort multipart requests at the global 30-second deadline.
Switch wiki processing jobs and page citations to application-assigned IDs: the PostgreSQL/Kingbase migrations define plain BIGINT primary keys without identity defaults, so database-generated keys fail on insert.
Eight entities (fact, fact contradiction, morning-card seen, wiki hot
cache / relation / transformation / transformation run / image caption
cache) declared IdType.AUTO while their PostgreSQL-compatible migrations
define the primary key as a plain BIGINT with no identity default.
MyBatis-Plus omits the id column from the generated INSERT under AUTO,
so every insert fails with a NOT NULL violation on those databases —
silently on paths that only log a warning. Switch them to snowflake
ASSIGN_ID, which works on all dialects since auto-increment columns
accept explicit values. Add a parameterized contract test pinning the
id strategy for all eight entities.
Use const for a never-reassigned local, drop an unused v-for index,
add a default branch to an exhaustive switch the linter cannot prove,
and remove eslint-disable directives that no longer match enabled rules.
The lint script referenced eslint with --ext flags but the repo never had
an ESLint config file, so pnpm lint always failed. Add a flat config
(typescript-eslint recommended + vue essential) with legacy-code rules
downgraded to warnings, drop the flat-config-incompatible --ext flags,
and move pnpm build approvals from the no-longer-read
pnpm.onlyBuiltDependencies field to pnpm-workspace.yaml allowBuilds.
Give raw-material uploads a dedicated five-minute timeout and process file-picker and drag/drop uploads through a shared two-worker queue, so constrained uplinks no longer abort multipart requests at the global 30-second deadline.
Switch wiki processing jobs and page citations to application-assigned IDs: the PostgreSQL/Kingbase migrations define plain BIGINT primary keys without identity defaults, so database-generated keys fail on insert.
- Settings → System gains a 'default workspace storage path' item: validated
on save (absolute, creatable), applied immediately without restart, and
re-applied from the database on startup. Blank clears the override;
existing data is never migrated.
- Desktop local file/command tools get a renderer settings page (allowed
directory list with per-row delete, add via native picker, enable toggle,
tunnel status); the native dialog additionally gains a 'remove directory'
flow, fixing the whitelist that could only grow.
- System settings save surfaces backend validation errors as a toast.
- router.onError fallback: a failed route-chunk load hard-navigates to the
clicked route once (guarded against reload loops) instead of hanging
silently until a manual refresh
- warm all lazy route chunks during idle time after login, so sidebar
navigation no longer depends on live chunk fetches under load
- SSE executor switches to a virtual-thread-per-task executor, matching
the app-wide virtual-thread model
Let skill Python scripts install packages from a configurable pip index instead of the default PyPI. docker-compose passes PIP_INDEX_URL / PIP_TRUSTED_HOST into the container; for the desktop app (host JVM, no Docker env) SkillScriptExecutionService falls back to mateclaw.pip.index-url / trusted-host Spring config and injects them into the subprocess, auto-deriving the trusted host for plain-HTTP LAN mirrors. The runtime image gains pip and a build toolchain (with the PEP 668 marker removed so on-the-fly installs work), and the script timeout ceiling is raised to accommodate large installs.
Remove the planning document that landed at the repo root — design notes
belong in the design-doc tree, not the shipped repo root. Also recycle the
per-conversation snapshot map once its last tool-call entry is removed, and
translate a leftover non-English comment.
Wire MCP standard notifications/progress into the existing SSE stream so long-running MCP tool calls surface live progress instead of a bare spinner. A per-call progressToken maps back to (conversationId, toolCallId); ProgressAwareMcpToolCallback injects it into tools/call _meta and calls McpSyncClient directly (falling back to the delegate on error, and applying identity forwarding first). Progress events skip the ring buffer and are replayed from a latest-value snapshot on SSE reconnect. Frontend renders a gradient progress bar in ToolCallSegment when a running tool reports progress.
- Shell scan: a token normalizing to the filesystem root (//, /., /..) is
only skipped when the command carries no destructive verb; with
rm/rmdir/shred/srm present the scan fails closed, so 'rm -rf //' is
refused while sed empty replacements (s/pattern//) stay allowed.
- Chat-upload fallback: a boundary violation is waived only when the
requested path itself normalizes inside one of the conversation's
candidate upload directories; a basename match against a stored
attachment no longer clears the violation. Resolver/DB failures keep
the BLOCK finding. The unused candidate-roots resolve overload is
removed.
- Regression tests for destructive root tokens, sed allowance, the
fail-closed compound case, stored-upload-path allowance, basename
collisions, cross-conversation paths, and resolver failure.
Skip absolute-path tokens that normalize to the filesystem root in the shell boundary scan (shell syntax like sed's s/pattern// was misread as a path outside the workspace), and add a DB-backed chat-upload fallback in WorkspaceBoundaryGuardian: when a file-tool path triggers a boundary violation, resolve the conversation's real candidate upload roots so attachments stored in workspace-scoped directories are found even when the thread-local workspaceBasePath is null.
- ProgressLedgerService.upsert: the reserved-prefix guard rejects the write; the
comment said 'strip the prefix and continue', which no longer matches. Rewrite
it to describe the actual reject behavior.
- ActionNode: translate the class javadoc (including the B2 pinned-constraints and
B5 auto-backfill notes) to English per code style.
The tier toggle buttons carry both .row-btn and .tier-btn (equal 0,1,0 specificity), so the later-declared .row-btn width:30px/display:flex overrode .tier-btn's width:auto/line-height and clipped the label. Scope the rule to .row-btn.tier-btn (0,2,0) so the text-button properties win.
Adds a read-only GET /api/v1/settings/search-providers catalog (admin-gated, no secrets), grouped collapsible provider cards, and a schema-driven plugin config form. Breaks a SystemSettingService<->PluginManager circular dependency via parameter @Lazy (with a context smoke test), and fixes PluginManager.updateConfig to merge instead of overwrite so omitted/blank secret fields are preserved. Hardens plugin search-provider id validation (reject-not-trim, case-insensitive conflict) and insulates the provider bridge hot path from throwing plugin code.
- Remove "(issue #477)" internal planning references from shipped Java
(SearchProviderRegistry, PluginSearchBridge, and the two new tests) — code
should describe what it does, not point at issue trackers.
- Drop "openclaw" external-project attribution from the search provider
comments (SearchProviderRegistry, SearchCache, SearchQuery), restating them
as objective functional descriptions.
- requireSessionOwnership now also checks session.kbId() == path kbId (404 on
mismatch), so a research session started under one KB cannot be addressed via
another KB path even when the caller's key is bound to both — defense-in-depth
on top of the keyId ownership check.
- Drop internal "R7" / "review #446" markers from the controller Javadoc in
favour of functional wording.
- Import Set/Map/concurrent types and static any() instead of inline FQNs in the
new kb-open research/auth tests, per code style.
* feat(kb-open): Deep Research open API (start/SSE/status/cancel)
Implements the async Deep Research endpoint for the KB Open API (#443).
Research is a multi-step LLM pipeline (plan → retrieve+draft → compose)
that runs asynchronously and broadcasts progress via SSE.
Endpoints:
- POST /{kbId}/research start (returns sessionId + streamUrl)
- GET /{kbId}/research/{id}/stream SSE progress (?token= for EventSource)
- GET /{kbId}/research/{id}/status query status / final report
- POST /{kbId}/research/{id}/cancel cancel running session
Components:
- KbOpenResearchController: 4 endpoints, @RequireKbScope("kb:search")
- KbResearchSessionRegistry: in-memory session tracking with keyId
ownership (a caller can only query/cancel their own sessions)
Security:
- R7: SSE uses ?token= query param (KbOpenApiAuthFilter already supports
this fallback for EventSource which can't set Authorization headers)
- Session ownership: status/cancel/stream all verify keyId match
- Cancel checks session is RUNNING (409 otherwise)
Reuses existing WikiResearchService.research() + ChatStreamTracker for
the actual research pipeline and SSE broadcasting.
Tests (6 new, all green):
- KbResearchSessionRegistryTest: register/complete/fail/cancel lifecycle,
cancel-on-completed no-op, unknown session returns empty
Closes#443
* fix(kb-open-research): cooperative cancel, sticky terminal, TTL, concurrency cap
Review #446 — address all 4 job-lifecycle/cost blockers + nits:
1. Cooperative cancellation (was: cancel only flipped status, pipeline ran
to completion). Cancel endpoint now calls streamTracker.requestStop();
WikiResearchService.ensureNotCancelled() checks isStopRequested at each
stage boundary (plan→draft, draft→compose) and inside the parallel draft
fan-out — so cancel actually halts the expensive LLM calls, not just the
SSE stream. Throws ResearchCancelledException (caught locally, no error
broadcast).
2. Sticky CANCELLED terminal. complete()/fail() now no-op on a CANCELLED
session, so a user who cancelled never sees a COMPLETED report surface
via /status.
3. Session registry TTL. Terminal sessions get an updatedAt timestamp and
are evicted by a @Scheduled sweep after
mate.kbopen.research.session-ttl (default 30m). RUNNING sessions are
never evicted. Prevents unbounded memory growth.
4. Per-key concurrency cap. startIfAllowed() rejects new research when a
key already has mate.kbopen.research.max-concurrent-per-key (default 3)
RUNNING sessions → 429. Stops one key from spawning ~60 parallel
multi-step LLM pipelines per minute under the per-min rate limiter.
5. Inline FQN → import (controller LinkedHashMap, test List.of).
Nits (inherited from P0-A rebase):
- V162→V164, prefix VARCHAR(12), design doc moved to rfcs/.
- Design doc: kb:search scope row now documents it covers /research/**.
31 tests pass (12 registry incl. sticky-cancel/concurrency/TTL +
13 service + 4 rate limiter + 4 controller + ...).
* fix(kb-open): scope-limited ?token= SSE auth fallback in KbOpenApiAuthFilter
R7: the SSE progress stream (/research/{id}/stream) is consumed by browser
EventSource, which cannot set an Authorization header. The filter's
extractBearerToken() never read ?token= (still a TODO), so the SSE endpoint
was unreachable from the browser — the headline use case got 401.
Fix: accept ?token= ONLY on SSE stream paths (isSseStreamPath, suffix
/stream), reject it everywhere else so the API key does not leak into
access/proxy logs for normal calls (R5). Matches the JwtAuthFilter convention
(getRequestURI logs carry no query string).
Also bypass the per-minute rate limiter on the SSE path: EventSource
reconnects/heartbeats would otherwise burn the key's window and 429 its own
POST /research start. Rate limiting belongs on the cost-producing endpoints.
Tests (6 new, KbOpenApiAuthFilterTest):
- non-SSE: header passes, ?token= rejected (no authenticate call)
- SSE: ?token= authenticates, missing token → 401
- SSE: bypasses rate limiter; non-SSE still hits it
* fix(kb-open-research): make per-key concurrency cap atomic (no check-then-act race)
startIfAllowed() did stream-and-count then put() — not atomic. Two
concurrent starts for the same key could both pass the count check (both
see < cap) and both put, admitting more sessions than the cap. On the
virtual-thread start endpoint this is a real DoS/cost-bypass path.
Fix: maintain a per-key AtomicInteger running counter (runningPerKey),
incremented atomically on start (incrementAndGet + rollback on overflow)
and decremented on each RUNNING→terminal transition (complete/fail/cancel).
The counter is kept in lock-step with status==RUNNING; since terminal
states are sticky, each session decrements exactly once.
cancel() also rewritten to capture the pre-transition state cleanly (the
old return check relied on Map.computeIfPresent returning the new value,
which worked but read as 'before.status==CANCELLED').
Tests (+2): cancelled/failed release slot (counter consistency), and a
concurrent-start test (12 virtual threads, cap=3) asserting exactly cap
admits — would be flaky/fail under the old impl.
* refactor(kb-open-research): remove unused register() back-compat method
register() was left over from the initial impl — it bypassed the per-key
concurrency cap (no startIfAllowed check) and, after the atomic-counter fix,
incremented runningPerKey without any overflow rollback. With no production
caller (the start endpoint uses startIfAllowed), it only existed for tests to
set up a RUNNING session. Drop it and route the tests through startIfAllowed
so nothing can accidentally ship a path that ignores the cap.
After the workspace-aware chat-uploads change, the upload root became
absolute (the resolver normalizes via toAbsolutePath/normalize, and the
autoconfiguration rewrites baseDir to an absolute path). ChatController.upload
then set ChatUploadResponse.path to that absolute path — despite the inline
comment promising a relative path "to avoid exposing the server's absolute
path". The field is rendered into the LLM prompt ("附件: foo (path)") and
returned to the client, so this leaked the server filesystem layout into both
the prompt and the response, and broke portability if the deploy dir moves.
Extract toRelativeUploadPath(uploadRoot, convId, storedName) which makes the
path relative to the upload root's parent (preserving the trailing sub-dir
name, e.g. chat-uploads/{convId}/{storedName}) and normalizes separators to
'/'. Retrieval is unaffected: it goes through the basename-based
ChatUploadResolver and the /api/v1/chat/files/... URL, not this field.
Adds ChatControllerUploadPathTest (default root, absolute workspace-scoped
root, custom base-dir name) asserting the result is relative and leak-free.
Addresses the blocker item in #452.
Adversarial review of PR #464 found that classify() promoted an absent
channelType to the 'authenticated' trust branch, stamping an untrusted
ThreadLocal username (e.g. stale value on a reused thread, or internal
tasks like SkillConsolidation/Reflection that carry no channel) with
authenticated trust — contradicting the fail-closed contract the service
documents.
- classify(): channel==null/blank now resolves to NONE (no injection);
only the explicit 'web' channel may yield authenticated. Unrecognised
non-web channels downgrade to external, never authenticated.
- signingKey(): replace the one-shot keyParseAttempted latch with
lastAttemptedPem so a corrected/hot-reloaded PEM re-parses on the next
call without an app restart. Still fail-closed when PEM is unchanged.
- Tests: 4 new cases lock the regression (null+dirty-ThreadLocal->NONE,
blank->NONE, novel channel->external, self-heal after config fix).
- .gitignore: exclude local .codebase-memory/ agent index.
MCP+identity suite: 93/93 green.
The first cut of the per-request interceptor called route.resume() for every
request. Playwright follows server-side 3xx redirects internally on resume()
WITHOUT re-invoking the route handler, so a public page that 302s to a
metadata IP still reached it — verified via runtime E2E (the handler only ever
saw the httpbin.org URLs, never the 169.254.169.254 redirect target).
Fix: for navigation requests, fetch with maxRedirects=0 and validate the
Location of each hop through UrlSafetyChecker before fulfilling; abort when a
hop resolves to a blocked host. Subresources/fetches keep the direct per-URL
check + resume path. Non-navigation and non-http(s) requests are unaffected.
Runtime-verified: httpbin.org 302 -> 169.254.169.254 is now aborted
(net::ERR_FAILED; log "blocked redirect ... cloud-metadata endpoint"), while
example.com and wikipedia.org (rich subresources) still load with no false
blocks.
Two SSRF hardenings on top of the private-network deployment mode:
1. Redirect / subresource re-validation. The SSRF guard previously ran only on
the initial navigation URL in the tool layer, so a public page that 302s to
169.254.169.254 (or a script fetch / img to a metadata IP) reached the target
unchecked — worse now that private-network mode exists. Install a per-context
request interceptor (BrowserLauncher.applyContextDefaults) that re-runs
UrlSafetyChecker on every http(s) request and aborts blocked ones. Non-network
schemes (data:/blob:/about:) pass through; unexpected checker faults fail open
so a transient error cannot wedge the page (the initial URL was already checked).
2. Allowlist can no longer open a cloud-metadata endpoint. Metadata hostnames and
IPs are now checked BEFORE the allowlist short-circuits, so an operator entry
like 169.254.0.0/16 or metadata.google.internal can never expose instance
metadata. Ordinary private-host allowlisting is unaffected (regression-tested).
Also correct the 192.0.0.192 comment (Oracle Cloud IMDS, not Azure).
The per-context setIgnoreHTTPSErrors was gated on ignoreHttpsErrors alone,
while the Chromium command-line cert flags require both ignoreHttpsErrors AND
allowPrivateNetwork. Setting only PLAYWRIGHT_IGNORE_HTTPS_ERRORS therefore
disabled certificate validation for all browser traffic, including the public
internet (MITM exposure). Gate the per-context bypass on allowPrivateNetwork
too, so ignoring HTTPS errors is scoped to LAN deployments — matching the
command-line path and the documented intent.
Also correct a comment: 192.0.0.192 is Oracle Cloud's IMDS address, not Azure.
The identity forwarded to opt-in MCP servers was a one-dimensional string
(ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat
visitorId for visitors and an IM sender id for IM — indistinguishable to the
REST backend. The signed-token mode (d204b702) made this worse: an RS256
signature over an unauthenticated visitorId reads as "MateClaw authenticated
this user" to any backend that trusts the signature.
Introduce an identity-typing dimension at McpIdentityForwardService:
- classify() branches on ChatOrigin: authenticated (web login, sub=immutable
userId), anonymous (webchat visitor, trust=anonymous), external (IM sender,
trust=external), or none (cron/system → nothing injected, fail-closed).
- mint() adds `trust` and `channel_type` claims; plaintext value is prefixed
`trust:subject` so backends can tell the kinds apart without a JWT.
The immutable userId reaches resolve() without coupling it to the user store:
JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths),
and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId
field (only-add, per the record's evolution rule).
Resolves the webchat semantic mismatch raised in #459 and the "sub should be
an immutable user id" follow-up. 82 tests green (4 identity classes covered
with claim assertions + full ChatOrigin/MCP regression).
(cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
Replace java.util.Set.of / java.util.Map.of inline fully-qualified calls with
top-of-file imports per code style (test sources sync to the open-source repo).
* feat(mcp): forward authenticated user identity to opt-in STDIO MCP servers
A STDIO MCP server is one shared subprocess per configuration; its env is fixed
at spawn and STDIO has no per-request header channel, so per-user identity must
travel in-band with each tool call. Previously nothing carried it, so an MCP
server could not call its downstream REST backend on behalf of the acting user.
Inject the authenticated username (from ToolExecutionContext) into each tool
call's JSON arguments under the reserved key `__mateclaw_user__`, for servers an
operator explicitly opts in via `mateclaw.mcp.identity-forward.servers` (by name
or id). The MCP server reads/strips it and forwards on-behalf-of alongside its
own backend API key.
- McpIdentityForwardProperties: per-server opt-in allowlist (name or id).
- IdentityForwardingToolCallback: wraps an MCP callback, merges the username
into the args; injected by trusted code, overwrites any LLM-supplied value
(no spoofing); forwards unchanged when there is no user or args aren't an
object/are malformed.
- McpClientManager: captures server names; wraps opt-in servers' callbacks
inside the prefix wrapper (so name-prefixing / return-direct still see the raw
delegate). Non-opt-in servers are untouched — username never leaks to them.
- Tests: injection, LLM-value overwrite, empty/non-object/malformed inputs,
no-user passthrough, opt-in matching by id/name.
- Docs (zh/en mcp.md): opt-in config, `__mateclaw_user__` contract, FastMCP
Python skeleton, trust model.
Default off (empty allowlist) — zero behavior change for existing servers.
Plaintext username suits a trusted-network REST backend keyed by an API key;
a signed short-lived token is noted as the stronger-isolation follow-up.
* feat(mcp): add signed-token trust model for MCP identity forwarding
Plaintext username forwarding makes the REST backend trust an unverifiable
assertion from the (shared, LLM-adjacent) MCP service — a confused-deputy model.
Add an opt-in signed-token mode so identity crosses the trust boundary as a
short-lived RS256 JWT the backend can verify with a public key.
- McpIdentityForwardProperties: nested `token` config (enabled, issuer,
ttl-seconds, key-id, private-key-pem, audiences) + USER_ARG/TOKEN_ARG keys.
- McpIdentityForwardService: resolves the injection — plaintext username
(__mateclaw_user__) when token mode off, else a minted RS256 JWT
(__mateclaw_token__) with sub=user, aud=server, short exp, jti. Lazy key
parse; fail-closed when token mode is on but the key is missing/unparseable
(no silent downgrade to plaintext). Signs with MateClaw's private key so the
backend only needs the public key (cannot mint/impersonate).
- IdentityForwardingToolCallback: now delegates the what-to-inject decision to
the service (keyed by per-server audience); static withClaim() keeps the
JSON-merge logic (overwrites LLM-supplied key, leaves non-object/malformed
args untouched).
- McpClientManager: injects the service; passes service + audience through the
wrap path for opt-in servers only.
- Tests: token mint+verify (with an in-test RSA keypair, asserting sub/aud/iss/
exp/jti), plaintext mode, no-user and no-key fail-closed, audience resolution.
- Docs (zh/en): token config, key generation, claims, REST-side verification
example, public-key distribution + JWKS-endpoint follow-up.
Default unchanged: token.enabled=false → plaintext (back-compat); whole feature
still opt-in per server and off by default.
mate_memory_recall.filename is VARCHAR(256), but the snippet-level recall
tracker assembles the key as `path + '#' + H2-heading-slug`. When the LLM
writes an over-long daily-note heading (the summarize prompt placed no
length cap on the `##` title), the CJK-preserving slug pushes the filename
past the column, and writes fail with Data too long / string too long.
Three layers of defence, root cause + hard caps:
1. prompt (source) — summarize-system.txt now asks for short (≤30 chars)
`##` titles; details go in the body, not the heading.
2. slug cap (close to source) — MemoryRecallTracker.sanitizeSectionKey
caps the slug at MAX_SECTION_SLUG=200, leaving path+'#' well under 256.
3. write-side cap (catches every path) — MemoryRecallService.recordRecall
truncates filename to MAX_FILENAME_LENGTH=255 at the entry point, so
the select/insert/update branches share one value and the dup-key
concurrency fallback still matches. Covers trackActiveRetrieval too,
which bypasses sanitizeSectionKey.
Tests: MemoryRecallFilenameTruncationTest covers both caps (over-long CJK
heading, normal heading untouched, ascii slug, date prefix survives) plus
an end-to-end assertion that the stored value fits VARCHAR(256). Existing
memory-suite unit tests still green.
Replace the single inline org.assertj.core.api.Assertions.assertThat call
with the static import already used for assertThatThrownBy, per code style
(test sources sync to the open-source repo).
* feat(kb-open): P0-B 9 open API endpoints
Implements the 9 read-only KB Open API endpoints on top of the P0-A
auth skeleton (#441). Each returns an explicit DTO (A5: never raw
entities) and delegates assembly to service-layer methods that return
pure DTOs (A6: no HTTP coupling, MCP-ready).
Endpoints:
- GET /pages/{slug} entity card (mode=summary/full/section:{heading})
- POST /search hybrid retrieval (granularity=entity/chunk)
- POST /search/chunks chunk-level semantic search
- POST /pages/{slug}/traverse entity relation graph (depth ≤ 2)
- GET /pages/{slug}/trace provenance (page → chunk → raw)
- GET /taxonomy pageType/entityType/relationType enumeration
- GET /whats-new recent changes + stale pages
- GET /stats KB statistics
- GET /pages lightweight page list
Components:
- KbOpenApiController: 9 endpoints, each @RequireKbScope annotated
- KbOpenApiService: assembly layer (card, traverse BFS, metadata parsing)
- KbOpenApiDtos: all response DTOs as records (PageCard, TraceResult,
TaxonomyResult, KbStats, WhatsNewResult, TraverseResult, PageList)
Traverse (pragmatic version):
- depth ≤ 2 with explosion guard, predicate LIKE matching
- slug → pageId → mention → primaryEntity (salience-highest)
- neighbor nodes echo slug when available (R11)
- edge sourceHandle via evidenceChunkId → citing page
Tests (4 new, all green):
- KbOpenApiControllerTest: 404 on missing page/slug, delegation to service
Closes#442
* fix(kb-open): address review feedback on #445
BLOCKERS:
- stats.pagesWithLinks always returned 0 because listByKbId() nulls out
content. Switch to listByKbIdWithContent() so [[wiki link]] detection works.
- Test file: replace inline java.util.List.of() FQN with import + simple name
(sync-opensource would expose the unidiomatic style).
NITS (inherited from P0-A rebase):
- V162→V164, prefix VARCHAR(12), FQN imports, parseScopes trim, ?token=
fallback removal, design doc moved to rfcs/ — all now in ancestor commit
6fd62440.
EXTRA:
- whatsNew staleReason: hardcoded Chinese "上游 fact 页面变更" → English
"Upstream fact page changed" (external-facing API response).
* chore(wiki): drop RFC-012 prefix from progress field Javadocs (#449 nit)
Per #449 review (4825113234): the internal RFC-012 reference should not
appear in code. progressPhase/progressTotal/progressDone Javadocs still
carried the "RFC-012 M2 v2 UI:" prefix after #449's English translation
pass — drop it now that these lines are touched.
Zero behavior change.
* chore(kb-open): drop inline FQN in parseScopes (#444 nit)
Per #444 review (4825157096): parseScopes used
`.collect(java.util.stream.Collectors.toUnmodifiableSet())` while
`Collectors` is already imported at the top of the file. Use the simple
name. Zero behavior change.
@SpringBootTest + H2 coverage asserting that both listRecent and search exclude
a still-running sibling conversation (stream_status='running') and the caller's
own current conversation, so concurrent sessions of the same agent cannot leak
into each other's session_search results.
Follow-up to #447. The generated-file link extraction accepted any
non-')' text before the path, so a paren-free javascript:/data: URL
embedding /api/v1/files/generated/<id> could be captured and bound to an
<a href>, enabling XSS on click. Adopt the scheme-restricted pattern
already used by SegmentSupersedeDetector and the channel adapters, on
both backend (ChatController) and frontend (useChat). Also replace the
inline fully-qualified Pattern/Matcher with imports and drop an unused
run-overview i18n key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract generated-file download links from tool results — on the backend (persisted to message metadata for history) and on the frontend (live during SSE) — de-duplicate by URL, and render them as a Generated Files section with file-type icons and a rail badge.
Every endpoint now binds its independent id param to an authorized KB: rawId/chunkId resolve-then-workspace-check, pageId is asserted to belong to the path kbId, and slugs stay kbId-scoped. Adds unit tests for same-KB/cross-KB/unknown cases.
Pure style cleanup, zero behavior change: replace inline FQN return type in WikiRawMaterialService.listFailures with an import + simple name, and translate the new WikiRawMaterialEntity field Javadocs to English.
Propagates structured error codes through the KB processing pipeline, surfaces silent sub-step warnings as a non-failure warning state, and adds a cross-KB failure center for aggregated visibility.
Lets an employee pin an ordered fallback chain of (provider, model) entries; the same provider may appear multiple times with different models. Build-time dedup keys on exact (provider, model).
Two compounding causes made the management view jump from the config
tab back to 'raw' a few seconds after the user selected it:
1. The tab-snap watcher used a single getter returning a new array
(`() => [currentKB?.id, workspaceMode]`). Vue compares the returned
value with Object.is, so a fresh array reference reports a change on
every re-evaluation — including background refreshCurrentKB() calls
that reassign the KB object with the same id. That re-ran the snap and
forced activeTab back to 'raw'. Switch to an array of getters so each
source is compared individually and the callback fires only on a real
id/mode change.
2. RawMaterialPanel's onBeforeUnmount cleared the SSE stream and the 60s
fallback timer but not the per-raw jobPoller setTimeout chain. While a
raw was still processing, leaving the sources tab left that 3s poller
running, calling refreshCurrentKB() indefinitely. Clear jobPoller on
unmount as well.
The config tab pane (.tab-content--config) was set to overflow:hidden,
mirroring the graph pane, but its inner .wiki-config has no bounded height
so its own overflow-y:auto never triggers. Tall config content (model
strategy / processing rules / search-preview cards) overflowed off-screen
with no scrollbar.
Switch the pane to overflow-y:auto like the generic .tab-content. The
existing <=980px media query (overflow:visible) keeps mobile page-scroll
intact. Pure CSS, no logic change.
The chat composables (useStickToBottom / useStream / useMessages / useTyping)
carried '参考 @agentscope-ai/chat …' attribution comments. That package is not
a dependency and is never imported — the lines were pure citation. Rewrite them
as objective functional descriptions so shipped code does not name external
projects.
- useChat.ts reconnectStream: cast the reused assistant message id to string
when calling updateMessage; the id is optional in the Message type, so the
raw value broke the vue-tsc build (TS2345, undefined not assignable).
- useStickToBottom.ts handleScroll: restore the block's indentation (it had
drifted to 1/3-space) and add a comment for the scroll-up release branch.
Verified: vue-tsc --noEmit passes; snowflake precision check clean.
Add unit coverage for the StageInstructions custom deserializer: plain-string
shorthand, full object with instructions+template, unknown-field skipping, and
both forms coexisting on one WikiPageTypeDef (backward compatibility).
Jackson treated the @JsonCreator factory method as a properties creator
(matching the 'instructions' parameter name to the JSON field), not a
string/delegating creator, so plain-string values still failed at runtime
with "no String-argument constructor/factory method".
Replace with @JsonDeserialize + StdDeserializer that explicitly checks
VALUE_STRING vs START_OBJECT tokens, handling both shorthand strings and
full {instructions, template} objects.
- resolveAgentBasePath: the relative-override branch now normalizes the
resolved path and rejects values that escape the workspace root via "../"
(the absolute branch already did this), keeping attachment/media/tool I/O
contained when an agent's workspaceBasePath is a relative override.
- cleanAttachmentFiles: return early on a null/blank conversationId so a bare
upload root can never be walked and deleted wholesale.
- Translate the chat-upload Javadoc/comments to English (cleanAttachmentFiles,
BaseAgent image-path resolver) per code style.
- Add a resolver test for the relative-override escape fallback.
Strict OpenAI-compatible providers reject the /chat/completions request
with HTTP 400 when an assistant message in history carries a tool call
whose function.arguments is not parseable JSON. Normalize blank or
non-JSON arguments to "{}" at the send chokepoint so streaming,
history-replay, and older-persisted tool calls all stay well-formed.
SsoService / SsoStateService / SsoController / SsoProviderRegistry were
unconditional component-scanned beans, but their configuration
(SsoProperties) is only registered by the conditional auto-configuration.
With SSO disabled (the default) the services were still instantiated and
startup failed: "required a bean of type SsoProperties that could not be
found". Gate the four beans on the same mateclaw.sso.enabled=true
condition so the SSO stack loads as a unit — disabled = no beans and no
exposed endpoints; enabled = the auto-configuration provides SsoProperties
and everything wires.
Verified: backend starts clean with SSO disabled (default).
- Replace inline fully-qualified names with top-of-file imports across
SsoService / SsoStateService / FeishuSsoProvider (ObjectMapper, Autowired,
Map.of, Date, DuplicateKeyException, Mac, URLEncoder) per code style.
- createSsoUser: roll back the freshly inserted user on any non-duplicate
identity-insert failure, preventing passwordless orphan accounts. The two
inserts share no transaction — the method is self-invoked and the enclosing
callback performs a network call, so a method-level @Transactional would not
apply; an explicit rollback in the catch is the correct guard here.
* feat(sso): feishu OAuth2 single sign-on (ISSUE #405 P0)
Implements the SSO design (ISSUE #405) with feishu as the first IdP
and a generic OAuth2 provider abstraction for future dingtalk/wecom
extensions. SSO is disabled by default — existing deployments are
unaffected until mateclaw.sso.enabled=true.
Backend:
- SsoProvider interface + SsoUserInfo record: generic IdP abstraction
- FeishuSsoProvider: OAuth2 authorization-code flow (app_access_token
with Caffeine cache → user_access_token → user info). apiBase switches
between feishu.cn / larksuite.com by domain config.
- SsoProviderRegistry: conditional registration, lists enabled providers
- SsoStateService: HMAC-signed OAuth2 state + self-contained bind_token
JWT, both persisted to sso_state DB table for multi-node correctness.
State is one-time-consumable (conditional UPDATE), bind_token jti
anti-replay via PK insert. Hourly ShedLock purge (LambdaQuery + Java
time, works on all 3 dialects).
- SsoService: authorize/callback/bind, user mapping (union_id first →
external_id fallback), auto-create with concurrent idempotency
(DuplicateKeyException → rollback orphan user → re-query), link-only
mode issues bind_token for existing-account binding.
- SsoController: 4 endpoints (/providers, /authorize, /callback, /bind)
all permitAll.
- V159 migration (h2/mysql/kingbase): mate_user_external_identity,
sso_state, ALTER mate_user.password NULL (SSO-only users).
- AuthService: generateToken promoted to public; login() guards
password=null (SSO-only users cannot password-login).
- SecurityConfig: /auth/sso/** added to permitAll whitelist.
- LoginRateLimitFilter: expanded to cover /auth/sso/bind (brute-force
surface equivalent to /auth/login).
- application.yml: mateclaw.sso.* config block (all env-var driven).
Frontend:
- Login.vue: dynamic SSO buttons (only shown when providers configured),
OAuth2 callback detection (?sso=callback), link-only bind dialog,
shared applyLogin flow (localStorage + workspace + route).
- api/index.ts: ssoApi (providers, authorize, callback, bind).
Tests: SsoStateServiceTest (11) — state issue/verify/replay/tamper,
bind_token issue/verify/anti-replay/garbage. Regression: PAT (23) +
Approval resolve (13) all green.
Not in scope (P1/P2): link-only bind/unbind management endpoints,
user enable/disable endpoint, dingtalk/wecom providers, admin SSO
config page. Workspace assignment for auto-created users remains a
product decision (design doc §12 item 2).
* fix(sso): self-review fixes — P0 security + P1 quality
P0-1 BindRequired serialization: replaced the R.fail(200, Map.toString())
hack with a structured SsoCallbackResponse record. Controller no longer
catches an exception for a non-error path; frontend reads bindRequired
flag directly instead of regex-parsing a stringified map.
P0-2 createSsoUser unbounded recursion: added a retry flag — second
DuplicateKeyException (extreme race where identity was concurrently
deleted) now throws a 503 instead of recursing to stack overflow.
P0-3 state TTL not enforced: verifyState's conditional UPDATE now
includes created_at > cutoff, so a state unused for 5+ min is rejected
at consumption time, not just at the 1h purge. Without this the 5-min
window was advisory only.
P1-5 SsoStateService unused ObjectMapper: removed dead injection.
P1-6 audit JSON string concat: replaced with ObjectMapper serialization
(provider/externalId no longer risk breaking the JSON structure).
P1-7 LoginRateLimitFilter shared counter: documented the intentional
decision that login + bind share a per-IP counter (same brute-force
surface) with guidance on switching to per-path if finer isolation
is needed.
* feat(desktop): support remote lite build mode without bundled JRE/JAR
Add a dual packaging mode system controlled by the BUILD_MODE env var:
- **local** (default): Full build bundling JRE + Spring Boot JAR, identical
to the previous behavior. Supports both embedded local backend and
remote server connection.
- **remote** (lite): Omits the ~530 MB JRE/JAR resources, producing an
installer that is ~81% smaller (97 MB vs 523 MB on macOS arm64). The
app only supports connecting to a remote server; the "local" option is
hidden from the splash connection chooser.
Changes:
- Replace static electron-builder.json with dynamic electron-builder.cjs
that conditionally includes extraResources based on BUILD_MODE
- Add build mode detection at runtime (checks JAR existence) with graceful
fallback to remote-only mode
- Add IPC handler app:get-build-mode and expose via preload
- Hide "本地运行" option in splash when running a remote build
- Ignore stale 'local' saved config in remote builds
- Add package scripts: package:mac:local, package:mac:remote, etc.
- Add missing build scripts: build.sh, download-jre.sh, build-all-platforms.sh
- Add no-op afterPack hook (trim-playwright-driver.cjs) placeholder
- Add cross-env devDependency for cross-platform BUILD_MODE support
* feat(desktop): add white-label branding system for build-time rebranding
Add a Vite plugin (scripts/branding.cjs) that replaces hardcoded "MateClaw"
strings at build time, enabling white-label/OEM rebranding without modifying
any source code.
Configuration:
- Edit branding.config.json (name, tagline, team, copyright, appId, githubUrl)
- Or set BRAND_* env vars (BRAND_NAME, BRAND_TAGLINE, BRAND_TEAM, etc.)
Usage:
# Default build (MateClaw brand)
npm run package:mac
# Custom brand via env vars
BRAND_NAME=MyAI BRAND_TAGLINE="Smart AI Helper" npm run package:mac:remote
# Or edit branding.config.json and build normally
npm run package:mac:remote
Replacements applied at build time:
- Brand name (window title, About dialog, error messages, console logs)
- Tagline, team name, copyright line
- GitHub repo/issues URLs
- Logo file path
- electron-builder config (productName, appId, artifactName, dmg title, publish repo)
The branding plugin runs in Vite's transform hook, covering the renderer
(App.vue, index.html), electron main process, and preload script.
Server-coupled strings (H2 database name, Spring Boot property names) are
intentionally NOT replaced to avoid breaking backend compatibility.
---------
Co-authored-by: qiaozhipeng <qiaozhipeng@daojia-inc.com>
* feat(webchat): add approval resolve + replay for API-Key channel (ISSUE #413 P1)
Before this, a WebChat (API-Key) channel that hit a ToolGuard-protected
tool parked the turn in a pending approval the visitor could never
clear — it hung for 30 min until the GC timeout and the turn was
wasted. This PR closes the loop, mirroring the web ChatController.
A1 — no code change. tool_approval_requested already reaches the SDK
via ToolExecutionGuardHelper's streamTracker.broadcastObject (direct
SSE push, bypassing the StreamDelta path). Adding it to
forwardVisitorEvent would double-deliver; the default-drop is correct.
A2 — new /sessions/approve and /sessions/deny REST endpoints. Auth is
the existing visitorToken + conversationId ownership guard; the actor
is webchatUsername(visitorId), which resolves the 'no MateClaw
username' blocker noted in the old stopSession javadoc. Both broadcast
tool_approval_resolved so the SDK clears its banner in real time.
A3 — approve returns an SSE stream: resolveAndConsume (atomic DB +
metadata + memory), restoreChatOrigin (recovers the webchat origin
captured at createPending), then chatWithReplayStream replays the
tool call and continues the turn. Replay may re-trigger approvals,
which the existing tool_approval_requested direct push handles.
A4 — stopSession now sweeps pending approvals (denyAllByConversation)
and broadcasts each resolution, so stopping a stream no longer leaves
approvals lingering for the GC.
Tests: WebChatApprovalInteractionTest (7) — deny resolves + broadcasts,
deny auth/ownership guards, idempotent unknown-pending, stop sweep
clears pending, stop no-op when nothing pending.
Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
WebChatSchemaFieldsTest (5), WebChatWikiPageListTest (8),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).
* fix(webchat): IDOR guard + SSE hang fix (PR #415 review)
Addresses all review feedback from mateaix:
P0 IDOR (security): /sessions/approve and /sessions/deny accepted a
client-supplied pendingId without cross-checking it belonged to the
caller's conversation. A visitor could resolve / replay another
visitor's guarded tool call. Fix: getPending(pendingId) then assert
conversationId matches before resolving. Added getPending delegate on
ApprovalWorkflowService so the webchat controller (which holds the
workflow facade) can do the precise lookup.
SSE hang: approveSession's already-resolved / error branches broadcast
'done' before streamTracker.register/attach, so the event had no
subscriber and the SSE hung to the 10-min timeout. Fix: register+attach
first, then resolveAndConsume. Removed the now-duplicate register/attach
in the replay branch.
Tests: +2 IDOR cases (cross-visitor pendingId rejected 404; mismatched
pendingId rejected 404). denyResolvesPending now asserts via getPending
(findPendingByConversation returns the earliest pending, polluted by
cross-test map state). denyUnknownPendingIsSafe updated to expect 404
(no longer leaks pendingId existence). Isolated IDOR victim/attacker
visitor IDs to avoid cross-test conversationId collisions.
Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).
* style(webchat): use simple ChatOrigin name in approveSession (PR #415 review)
Reviewer flagged fully-qualified inline types (ResolveOutcome was fixed
in the prior commit; ChatOrigin was missed). Add the import and switch
the 3 FQN references in approveSession to the simple name, matching the
ResolveOutcome cleanup. chatStream's pre-existing FQN usages are out of
this PR's scope and left untouched.
Before this, a workflow await_approval step whose approverChannels
pointed at feishu/wecom was effectively dead for IM interaction. Even
after PR #414 (B1) pushed the notice to the IM group, clicking the
card's Approve/Deny buttons did nothing useful:
- Identity check (requester==clicker) failed-closed: wf- approvals
have userId=null (system-initiated), so every click was rejected.
- Even if it passed, the synthetic /approve injection was a dead end:
the router routes by conversationId, but wf- ids use a synthetic
workflow:run:{runId} key that no IM conversation matches, so
findPendingByConversation returned null and the /approve was fed
to the LLM as plain text.
B3 fix: both ToolGuardCardHandlers now detect the wf- prefix and
resolve inline (approvalService.resolve), bypassing the synthetic
injection entirely. The WorkflowApprovalResolvedEvent published
inside resolve is picked up by ApprovalResumeBridge (activated in
PR #414 B2), which resumes the paused run. This mirrors the Web /
WebChat resolve path (PR #415).
Identity policy: any audience member may resolve a wf- approval.
The card only reaches channels declared in await_approval's
approverChannels, so whoever sees it is a designated approver.
Regular tool approvals keep the strict requester==clicker guard.
Tests:
- wecom ToolGuardCardHandlerTest: +2 wf- cases (inline resolve, no
synthetic injection; already-resolved renders expired). Existing 6
cases updated for the new 3-arg constructor.
- feishu FeishuCardDispatcherTest: updated for the new factory
constructor signature.
Regression: ApprovalWorkflowServiceResolveTest (13), GcTest (7),
RecoveryTest (7), feishu dispatcher (4), button value (7),
renderer (3+3) — all green.
Two P0 fixes from ISSUE #413 — both address workflow await_approval
approvals that silently failed in production:
B1 — AwaitApprovalStepAdapter now dispatches the approval notice to
every channel in approverChannels that carries a target. Previously
approverChannels was write-only metadata: a workflow that declared
["feishu:oc_xxx"] silently dropped the notice and the IM group never
learned an approval was waiting. Element format is "channelType"
(no push, operator uses admin console) or "channelType:targetId".
Each channel failure is logged and skipped — it must not fail the step.
B2 — requestWorkflowApproval now registers the wf- approval into the
in-memory map via registerRecovered. Previously it only did
approvalMapper.insert, so getPending("wf-...") returned null,
performResolve short-circuited at the not-pending guard, the
WorkflowApprovalResolvedEvent was never published, and
ApprovalResumeBridge was dead code. With this fix, resolving a wf-
approval walks the full two-phase contract and the bridge fires.
Tests:
- WorkflowApprovalResumeBridgeTest (3): map registration, event
publish on resolve, safe no-op for unregistered wf- ids.
- AwaitApprovalNotifyTest (2): targeted channels dispatched, bare
"web" skipped, channel failure non-fatal.
Regression: ApprovalWorkflowServiceResolveTest (13), AwaitApprovalRuntimeTest (3),
DispatchChannelRuntimeTest (3), GcTest (7), RecoveryTest (7) — all green.
Add a floating back-to-bottom control to the chat message list that
appears when the user scrolls up away from the live bottom. The button
auto-docks to the right edge after 15s of inactivity (with a subtle
breathing pulse) and un-docks on mouseenter, keeping it unobtrusive
while reading history.
- End key jumps to the bottom, ignored when focus is in an input,
textarea, or contentEditable field.
- Explicit jump (button click or End) forces past the stick-to-bottom
escape lock and clears it so sticky auto-scroll resumes following new
content; automatic scrolls still respect the escape lock so they do
not fight the user reading history.
- Larger thumb-reach hit area and lower placement on mobile.
- New i18n key chat.scrollToBottom (zh-CN / en-US).
Add an async export feature on the Dashboard page -- global admins can
generate and download a multi-sheet operational data report (.xlsx
packaged as .zip). The export covers 9 sheets:
1. Overview - interval KPIs, system snapshot, 7-day trend, period comparison,
model details (configured providers only), agent activity ranking top 10
2. Token Usage - daily breakdown by runtime_provider with avg tokens/msg
3. Skill Stats - skill list with usage count, last-call time, bound agents
4. User Stats - per-(workspace, user) aggregated tokens, duration, last active
5. User Conversations - detail rows pairing user-asst messages
6. Security and Audit - unified view across 6 sources (guard rules, audit logs,
approvals, grants, config, business audit events)
7. Channel Stats - per-channel conversation count, tokens, unique users
8. Model Config - enabled plus API-key-configured models with parameters
9. Cron Jobs - execution records with duration and token usage
Backend highlights:
- generate/progress/download endpoints guarded by PreAuthorize hasRole ADMIN
- single AtomicBoolean lock (409 when busy), 90-day frontend cap, 5-min deadline
- metadata-based tool-call counting, deleted=0 filtering everywhere
- value label mapping (chat to dialogue, TRUE to enabled, etc.)
- one-time downloadToken, file auto-cleanup after 24h or download
Frontend highlights:
- SVG ring progress bar with smooth dashoffset transition plus slow rotation
- visibility gated by workspaceStore.isGlobalAdmin (v-if on button)
- 1-second polling driving progress state machine (idle/generating/done)
- Element Plus date-picker (30-day default, 90-day max)
- explicit SecurityConfig authorization for /swagger-ui*, /v3/api-docs*, /webjars/**
- public for local/default profile; admin-only (ROLE_ADMIN) by default in production DB profiles
- override via MATECLAW_OPENAPI_EXPOSE_UI; add RANDOM_PORT integration tests and docs
execute_code (bash/sh/shell) bypassed the workspace boundary guard, so shell
code run through it could read/write/delete paths outside the workspace sandbox
(e.g. cat /etc/passwd) while the same paths were blocked for read_file and the
shell tools. Bring execute_code under the guard (scan only shell-language code,
report the code param), and trust the tool-result spill roots so a legitimate
spilled result stays readable. Adds regression tests.
- Remove mateclaw-ui/package-lock.json and yarn.lock. This is a pnpm
monorepo where pnpm-lock.yaml is the only lockfile; the npm/yarn locks
were stray duplicates. Add a .gitignore rule so they are not committed
again by mistake.
- SourceEvidenceLedger: reference Pattern/Matcher by their imported
simple names instead of inline fully-qualified names.
Backend (SourceEvidenceLedger):
- appendWikiSourceTable now normalizes existing source lines in-place to
canonical "[N] Title - section - page N" format instead of skipping them
- Added replaceSourceLine helper that matches a full source line by regex
and replaces it with the canonical form
- When source lines exist without a "来源:" header, automatically insert
one so the frontend preprocessor can locate the source table
Frontend (useMarkdownRenderer):
- Added data-citation-index / data-citation-title to DOMPurify whitelist
- Added preprocessWikiCitations preprocessor: parses the canonical source
table to build an index-to-title map, replaces [n] markers in the answer
body with clickable <a> links, and wraps entire source-table rows so the
full line is clickable
- Integrated into the render pipeline after wikilink substitution and
before Marked parsing
Frontend (useGlobalWikilinkClick):
- Extended the click delegation selector to match both .wiki-link and
.wiki-citation elements
- Title extraction falls back: data-citation-title || data-wiki-title
Tests: added three test cases for source-line normalization, idempotency,
and automatic header insertion
Live lifecycle board (grid<->board toggle) plus an assignee-swimlane plan
board that groups follow-up re-runs of one goal into a single xN card.
Custom right-side detail/goal panels with markdown output. Fixes plans
being persisted under the per-run trace id so the board actually populates.
Closes#385
The cherry-picked V154 used 'ALTER TABLE ... ADD COLUMN IF NOT EXISTS' for
MySQL — invalid on MySQL 8.0.x (a MariaDB-only extension) which aborts Flyway
at startup. Switch to the INFORMATION_SCHEMA + PREPARE guard the other MySQL
migrations use. Also change the KingbaseES column from SMALLINT to BOOLEAN to
match the Java 'Boolean wikiDisabled' field and the existing skills_disabled /
tools_disabled flags (vanilla PostgreSQL is strict about boolean vs smallint).
H2 (already BOOLEAN) is unchanged.
Issue #304. Operators who want an agent with NO knowledge base had no
way to express it: leaving the KB picker empty fell through to "inherit
workspace-wide" (every KB visible), so the agent ended up ingesting
every KB's context. This adds the same opt-out toggle that
skills_disabled (V126) / tools_disabled already provide.
Backend:
- V154 migration (h2 + mysql + kingbase): mate_agent.wiki_disabled
BOOLEAN/TINYINT/SMALLINT NOT NULL DEFAULT FALSE. Legacy agents stay
bit-identical.
- AgentEntity.wikiDisabled: Boolean field, @TableField("wiki_disabled").
- AgentBindingService.getBoundKbIds: short-circuit at the top —
wiki_disabled=true returns Set.of() regardless of binding rows. Mirrors
the precedence contract of getBoundSkillIds vs skills_disabled.
- AgentBindingService.setKbBindings: a non-empty save auto-clears a
stale wiki_disabled flag (same contract as setSkillBindings /
setToolBindings on their respective flags). Empty saves leave the flag
untouched — the UI toggle owns the bit, not the binding writer.
- AgentBindingServiceWikiDisabledTest: 5 cases covering all three
return states + the stale-flag auto-clear + empty-save no-op.
Frontend:
- Agents.vue KB picker: add the "此智能体不使用任何知识库" /
"This agent uses no knowledge bases" toggle, mirroring the skills /
tools picker layout. Tab badge shows "Off" when the toggle is on.
- types/index.ts: add Agent.wikiDisabled?: boolean.
- Save logic: when wikiDisabled is on, send an empty KB list (the
setKbs contract then leaves the flag alone server-side, exactly as
setSkills / setTools behave for their opt-out flags).
- i18n (zh + en): new strings for toggle label, hint, badge, and the
scope description shown when the toggle is on.
Stacked on top of #382 (which introduced AgentBindingResolver
.getBoundKbIds). No agent-runtime changes — wiki tools already degrade
cleanly when getBoundKbIds returns Set.of().
Add /wiki/pages row to endpoint table and a new "Wiki knowledge-base
reference ([[slug]] picker)" section explaining the directive-text
mechanism, query parameters, visibility rules (synthesis excluded,
100-page cap, KB-scope fallback), and curl examples (zh + en).
Follow-up docs for the wiki picker endpoint shipped in this PR.
Add GET /api/v1/channels/webchat/wiki/pages mirroring /skills, so
downstream integrators can build a [[slug]] picker UI that points the
LLM at specific wiki pages. The picker token format is the universal
Obsidian/Wikipedia wikilink convention; the LLM consumes [[slug]] via
the existing wiki_read_page(slug=...) tool, so no agent-runtime changes
are needed.
- AgentBindingResolver.getBoundKbIds(agentId): three-state mirror of
getBoundSkillIds. null = no rows (fall through to workspace-wide KBs),
Set.of() = explicitly scoped to zero KBs, non-empty = explicit scope.
- WebChatController.listWikiPages: API Key + visitorToken auth chain,
agentId workspace anti-escalation, visibility excludes pageType=
synthesis (LLM intermediate artifacts), 100-page cap forces keyword
filter, response carries only display-level metadata.
- WebChatWikiPageView DTO: kbId/kbName/slug/title/summary/pageType;
content/embedding/sourceRawIds deliberately stay admin-console-only.
- WikiTool.wiki_read_page @Tool description: document the [[slug]]
convention so the LLM treats each token as a wiki-page reference.
- WebChatWikiPageListTest: 8 cases covering happy path, keyword filter,
synthesis exclusion, anti-escalation, auth failures, cap behavior,
and the no-binding → workspace-wide fallback.
Closes#381.
Add /skills row to endpoint list table, note optional agentId on /stream,
and add a new "Skill invocation (slash picker)" section explaining the
directive-text mechanism with curl examples (zh + en).
Follow-up polish for the /skills endpoint shipped via PR #374.
- per-KB entity-type whitelist (config UI + persistence; empty = built-in defaults)
- entity graph: legend grouped by type with click-to-filter; nodes colored by type
- always show entity names on graph nodes (not only on hover)
- earthy categorical palette aligned to the app theme, shared by entity & page graphs
- theme-aware graph label color (resolve CSS var for canvas, light/dark correct)
- manual extract = full rebuild: idempotent force re-extraction + orphan pruning,
guarded against data loss on a fully-failed run
- regression test for force re-extraction; zh/en i18n
GET /api/v1/channels/webchat/skills?agentId=<optional>&visitorId=<required>
Headers: X-MC-Key + X-MC-Visitor-Token
Downstream systems integrating via the webchat SSE endpoint have no way
today to enumerate the skills a visitor can invoke — the existing
GET /api/v1/skills is JWT + workspace-role gated, unreachable from the
API-Key-authenticated webchat channel. Without a list, integrators
can't render a slash picker UI; visitors have to know skill slugs by
heart.
The new endpoint mirrors the /stream auth chain (resolveChannel +
verifyVisitorToken) and reuses AgentBindingResolver.getBoundSkillIds
to scope visibility. Only enabled skills explicitly bound to the agent
surface; agents with no explicit bindings return an empty list rather
than inheriting the global pool (the agent config stays the source of
truth for what surfaces in visitor UI). The agentId anti-escalation
guard from /stream is reused verbatim — an explicit agentId must
belong to the channel's workspace.
Returns WebChatSkillView (id / name / nameZh / nameEn / description /
icon). Deliberately omits SKILL.md content, configJson and
securityScanResult: those never leave the admin console.
Issue: #373
When a webchat visitorId + sessionId pair exceeds the conversation_id
column width, WebChatController#deriveConversationId folds the variable
part into a SHA-256 hash prefixed with `#`:
webchat:<key8>:#<sha256[0..40]>
That `#` is the URL fragment delimiter. Every URL the admin console
builds by interpolating the conversationId into a path — message list,
status, rename, pin, model, delete, goals/by-conversation, chat/stop,
chat/pending-approvals — gets truncated at the `#` before reaching the
server. Symptom: opening one of these conversations in the console
surfaces as 405 (GET landing on @DeleteMapping("/{conversationId}"))
and 403 (owner check on the truncated id).
Add an `encId` helper (encodeURIComponent) and apply it to every
conversationId path segment. The server's @PathVariable decoder already
handles the percent-encoded form transparently, so this is purely a
client-side fix that recovers every existing hashed-id row in addition
to any future ones.
Issue: #372
The previous notLike(column, "%:") form auto-wraps the value with extra %
on both sides AND escapes the user-supplied %, producing a %%:% pattern
that matches any id CONTAINING a colon — silently filtering out every
webchat:<key>:<visitor>, feishu:<chatId>, cron:<jobId> conversation from
the admin list / page. The frontend sidebar ends up empty.
Switch to notLikeLeft(column, ":") which only prepends the wildcard,
giving the intended NOT LIKE '%:' (does not end with a colon).
Strengthen the three malformedIdGuard tests to assert on the bound param
value ("%:" — ends-with colon) in addition to the SQL keyword, so this
regression cannot return silently. The assertions must call
getTargetSql() first to trigger MyBatis-Plus's nested-wrapper param
merge — getParamNameValuePairs() is empty on the parent until then.
conversationId ending in ":" (e.g. webchat:<key8>: with empty visitorId,
from older webchat versions) leaks into the admin console via the
'webchat:%' username LIKE, then 500/403s on open because the trailing ":"
makes some reverse proxies strip the path tail — landing a GET on the
@DeleteMapping variant of /{conversationId} (issue #369).
Add applyMalformedIdGuard — a NOT LIKE '%:' clause — to both listConversations
(lenient + strict overloads) and pageConversations so these rows never
surface. isConversationOwner already rejects unknown ids with 403, so no
change is needed on the direct-access endpoints; once the rows are out of
the lists, admin can no longer reach them.
The two existing strict/non-admin assertions changed from "no LIKE keyword"
to "no webchat:% param value" — applyMalformedIdGuard emits a NOT LIKE
itself, so the LIKE keyword is now present in every query.
Tests cover the guard on lenient, page, and strict paths.
Spring's default lets HttpRequestMethodNotSupportedException escape to the
catch-all @ExceptionHandler(Exception.class), surfacing as a 500 with a full
stack trace. Return a clean 405 so the client gets a structured R body and
the log stays at WARN.
This is also the second line of defence against malformed path segments that
confuse reverse proxies — e.g. a conversationId ending in ":" can make a
proxy strip the trailing path, landing a GET /api/v1/conversations/<id> on
the @DeleteMapping variant and triggering exactly this exception (issue #369).
Align the conversation list/page with the isConversationOwner cross-workspace
guard: only a global admin can open a webchat-owned conversation, so only
admins should see those rows. Previously listConversations / pageConversations
surfaced webchat principals to every authenticated user, who would then 403 on
opening them (list-vs-access asymmetry).
Also fixes ConversationServiceWebchatVisibilityTest, which still asserted the
pre-guard owner behavior and never mocked AuthService, so it threw NPE at
runtime once isConversationOwner started resolving the requester. The owner
matrix is covered by ConversationServiceOwnershipWorkspaceTest; this test now
pins the admin-gated list visibility for both admin and non-admin callers.
The webchat session-id (was V147) and archive/revocation (was V148) migrations
collided with the wiki migrations already occupying V147 (wiki_page_aliases)
and V148 (wiki_entity); wiki migrations run up to V150. Two files sharing a
version makes Flyway abort at startup with "Found more than one migration with
version N", so the app failed to boot on a fresh database. Renumber the webchat
migrations to the next free slots (V151, V152) across all three dialects. Table
names are unchanged, so entities and idempotent column guards are unaffected.
Closes the authorization asymmetry between list endpoints (which filter
by workspaceId) and direct-access endpoints (which did not): a logged-in
user could reach another workspace's system / IM / webchat-owned
conversation by id and run any of messages / delete / rename / pin /
setModel / clear / chat-files download on it.
Per the maintainer's guidance on issue #344, workspaces are now treated
as untrusted isolation boundaries — the fix is the cross-cutting
hardening, kept out of feature work.
Behavior change (only for shared, non-direct convs):
- requester is a global admin (user.role=admin) → pass
- requester is a member of the conversation's workspace → pass
- otherwise → deny
Preserved to avoid regressions:
- direct owner (username == conv.username) → pass without lookup
- convs without workspace_id (legacy rows) → legacy system-owner check
- anonymous user (authService returns null, e.g. permitAll reconnect)
→ legacy system-owner check
Callers in ConversationController / ChatController / SubagentController /
GoalController / ApprovalController (18 sites) are unchanged — the
signature stays isConversationOwner(conversationId, username). The
workspace membership check is done via WorkspaceService.hasPermissionCached
(Caffeine-backed, same cache the WorkspaceAccessInterceptor uses) and
ignores the X-Workspace-Id header, which is client-controlled.
Tests: 11 cases in ConversationServiceOwnershipWorkspaceTest covering
each branch of the new logic. No caller-side test changes — the 66
caller tests (ConversationService*Test, ChatController*Test,
SubagentController*Test, GoalController*Test, ApprovalController*Test)
still pass.
Previously WebChatController.chatStream silently dropped every agent
lifecycle event except _usage_final (and content_delta / thinking_delta
derived from delta.payload). Visitors sat with nothing between the
meta event and the first content chunk — typically 3–10s when the
agent plans / recalls memory / runs tools, longer when the agent
chained multiple tool calls. The JWT chat path (ChatController) had
this wiring; webchat did not.
Curated 4-event subset (per design review):
- phase — high-level phase transition (planning / generating /
summarizing / ...). SDK shows a "AI is thinking..."
typing indicator before the first token.
- tool_start — agent invoked a tool. SDK shows a localized badge
("Searching...", "Reading file.pdf", ...).
- tool_end — tool completed. SDK clears the badge.
- plan — Plan-Execute agents expose their step list. SDK can
render a checklist.
Deliberately NOT forwarded (internal noise / leak risk):
- _usage_final, _routing_decision — consumed internally
- finish_reason — implicit in `done`
- feedback_event — visitor can't retry/regenerate anyway
- perf_summary, iteration_* — internal metrics
- plan_step_started/completed — too granular; the plan event covers
the visitor's needs
Critical safety constraint: tool_start / tool_end carry ONLY the tool
name. Tool arguments and results are dropped — agent tool calls can
contain PII (file paths, user queries, credentials), and relaying
those to a 3rd-party website frontend is a data leak. The SDK maps
tool name → localized label via its own lookup.
Backward compat: existing clients ignore unknown event types per the
SSE spec, so adding these is non-breaking.
Tests: 5 new cases in WebChatStreamE2ETest covering each event type
+ a regression case asserting internal events are silently dropped.
85/85 webchat tests green.
Docs: docs/zh/webchat.md gains a "实时进度事件" subsection.
Stack: feat/webchat-attachment-e2e → feat/webchat-stream-phase-events
Follow-up to epic #355.
Adds WebChatAttachmentE2ETest — second HTTP-level test in the webchat
suite. Boots RANDOM_PORT, drives Spring's multipart parser for real,
and verifies the cross-endpoint wiring that turns an upload into an
agent-addressable attachment.
Coverage (7 tests):
- upload + /stream round-trip: persisted user message's content_parts
carries the file part with a server path that points into the
conversation's upload dir; bytes on disk match what was uploaded
- unknown attachmentId → silently dropped (no error, text-only parts)
- foreign visitor cannot reference another visitor's fileId
(consume() is conversation-scoped)
- upload without visitorToken → HTTP 401
- upload with disallowed extension → HTTP 400
- GET /files streams back the uploaded bytes
- GET /files without visitorToken → HTTP 401
AgentService is @MockBean'd so /stream returns instantly; what we
assert is the persisted user-message shape (DB row content_parts),
not the agent's actual file consumption (which would need a real
agent + tool runtime — out of scope for the wire-format focus).
Worth noting: RHttpStatusAdvice maps R.fail(401/400) to the matching
HTTP status, so 4xx assertions are on the HTTP status, not the body.
Stack: feat/webchat-stream-e2e-test → feat/webchat-attachment-e2e
Follow-up to PR #363.
Adds WebChatStreamE2ETest — first test in the suite to boot a real
servlet container (RANDOM_PORT) and exercise /stream over actual HTTP,
parsing the SSE wire format that any third-party SDK would see.
AgentService is swapped with a Mockito @MockBean so chatStructuredStream
returns canned StreamDeltas — fast, deterministic, no real LLM.
Coverage:
- happy path: meta → content_delta* → done, assistant reply persisted
- multi-chunk reply with thinking_delta + _usage_final event
(verifies persisted prompt_tokens / completion_tokens / runtime_model)
- bad API key → SSE error event "Invalid API Key"
- blank message → SSE error event "Message is required"
- channel with no bound agent → SSE error event "No agent configured"
- explicit sessionId → meta echoes it + seeds conversation namespace
- invalid visitorId charset → SSE error event
7 tests, ~5s. Mid-stream stop is covered by WebChatStopStreamTest at
the controller level; attachment ingestion is left for a follow-up
since it requires POST /upload first.
Stack: feat/webchat-docs → feat/webchat-stream-e2e-test
Epic issue: #355
docs/zh/webchat.md — single source of truth for downstream integrators.
Covers everything needed to embed MateClaw webchat into a third-party
site without reading source:
- Base URL, auth model (API Key + visitorToken), R<T> response wrap
- Endpoint table (14 visitor-facing + 2 admin)
- Auth flow diagram (how visitorToken gets minted on /stream, reused
on management endpoints)
- Error code table (400/401/404/409 with semantics)
- SSE event protocol (meta / content_delta / thinking_delta / done /
error)
- File upload + download flow (visitor-attached vs agent-generated;
/api/v1/files/generated/<uuid> is permitAll + 7d TTL)
- visitorToken revocation admin endpoint
- Three end-to-end curl examples (first message / list sessions /
upload-then-send)
- Limits (5 empty-session quota, upload caps, 7d expirations,
single-instance constraint today)
@Operation / @Parameter / @ApiResponse / @ExampleObject polish on
WebChatController is intentionally deferred — it's noisy mechanical
work that deserves its own focused PR rather than getting rushed in
here. The doc is the canonical reference now; the swagger annotations
can quote it.
Part of epic #355.
AuditEventService gains recordAs(actor, workspaceId, ...) — an overload
that takes an explicit actor string instead of deriving one from
SecurityContext. Used for non-MateClaw principals (currently just
webchat visitors), where there is no Spring Security auth and the
default record() path would write "system".
WebChatController injects AuditEventService and adds an audit() helper
that constructs the canonical actor string "webchat:<channelId>:<visitorId>"
so audit-event searches can filter by channel or visitor. Eight write
endpoints now log an audit row on success:
webchat.create-session, webchat.rename-session, webchat.pin-session,
webchat.archive-session, webchat.delete-session, webchat.stop-session,
webchat.regenerate-session, webchat.upload-file
/stream is intentionally NOT audited — message-volume noise, and
conversationService.saveMessage already leaves a durable trail.
Each row carries:
- username = webchat:<channelId>:<visitorId>
- action = webchat.<verb>
- resource = CONVERSATION / <conversationId>
- detailJson = {sessionId, ...action-specific fields}
WebChatAuditTrailTest (@SpringBootTest, 2 cases):
- createSession lands a row with the exact actor string + action
- rename + pin + archive + stop each leave a row (4 distinct actions)
Audit insert is async; tests poll up to 3s for the row to appear.
Regression: 9 webchat test classes (64 tests) green.
Part of epic #355.
Two cleanups promised in the plan, kept narrow to avoid cascading churn:
1. New WebChatErrors enum — single source of truth for the visitor-facing
HTTP error codes + messages. All future R.fail() calls can reference
WebChatErrors.INVALID_API_KEY etc. instead of bare literals. This PR
doesn't migrate every existing call site (that's a noisy sweep better
done in a follow-up); the enum just needs to exist so audit/OpenAPI
work in PR 7/8 can quote canonical messages.
2. pageSessions now delegates auth to listSessions instead of duplicating
the resolveChannel + verifyVisitorToken block. Same external behavior;
-15 lines of duplication. The pagination/keyword logic stays where it
is (it's specific to the /page variant and doesn't belong in
listSessions).
Visitor-token `required=true` migration from plan §6 was dropped: changing
it would flip missing-token responses from 401 to 400, which violates the
current error-code contract that visitors and tests rely on. The
`required=false` + explicit-verify pattern stays.
Regression: 7 webchat test classes, 42/42 green.
Part of epic #355.
New endpoint POST /api/v1/channels/webchat/sessions/regenerate. Behavior:
1. Auth (API Key + visitorToken + ownsConversation — same chain as the
other session mutations).
2. streamTracker.requestStop() — kill any in-flight stream first so its
doOnComplete doesn't race the delete/save below.
3. Find last role=user message (seed) and last role=assistant message
(target).
4. Delete the last assistant message if present.
5. Reuse chatStream by handing it a synthetic WebChatRequest whose
message is the seed user content. chatStream saves a fresh user
message (new id, same content) and starts the agent turn.
Trade-off: chatStream saves a new user message rather than replaying the
existing one in place, so the user-side message count grows by 1 per
regenerate. Acceptable — the alternative (refactoring chatStream into
reusable chunks) is a 4-hour distraction from the actual feature, and the
extra row is harmless (history still reads naturally: user, asst, user,
asst, user, asst instead of user, asst, asst).
ConversationService gains findLastMessageByRole() and deleteMessageById()
helpers; both are scoped exactly to what regenerate needs.
WebChatRegenerateTest (@SpringBootTest, 5 cases):
- empty thread (no user message) → error event, no DB change
- deletes the last assistant reply (count strictly decreases)
- bad token → no DB change (auth fails before mutation)
- unknown sessionId → returns emitter without throwing
- seeds from the LAST user message when multiple exist
Tests don't assert on the actual LLM stream content — that's left for
PR 5's WebChatStreamE2ETest, which mocks the chat model.
Part of epic #355.
Two new session-state mutations, both following the rename endpoint's
shape (PUT + {flag: true|false} body + visitorId/sessionId query):
- PUT /api/v1/channels/webchat/sessions/pinned — flips mate_conversation.pinned
- PUT /api/v1/channels/webchat/sessions/archive — flips mate_conversation.archived
Archive complements delete as a "soft-close" — the thread stays on disk
(history preserved, addressable, downloadable) but is hidden from the
default /sessions listing. Pin makes a thread sort first in the visitor's
listing, mirroring the admin-console behavior.
Archive dominates pin: an archived+pinned thread is still hidden by
default. Callers opt back in via includeArchived=true (added in PR 1).
ConversationService gains setArchived(), mirroring the existing
setPinned() pattern.
WebChatArchivePinTest (@SpringBootTest, 6 cases):
- pin flips column + view reflects pinned=1
- archive hides from default listing, includeArchived=true shows it,
un-archive restores
- archived+pinned still hidden (archive dominates)
- malformed body / wrong type → 400
- unknown sessionId → 404
- bad token → 401
Part of epic #355.
Closes the "no way to ban a single visitor without burning the global
JWT secret" gap from the epic. Two changes:
1. Token format: HMAC payload now includes exp, format is
`<base64sig>.<expEpochSec>`. Default TTL 7 days (VISITOR_TOKEN_TTL_SECONDS).
Expiry participates in the HMAC, so bumping it client-side invalidates
the signature. /stream still mints fresh tokens on first contact — a
revoked visitor can start a new /stream (gets a new token), they just
can't use the old one on management endpoints.
2. WebChatTokenRevocationService — DB-backed registry (webchat_revoked_visitor
table from V148) with a 5-minute Caffeine cache in front. revoke() /
unrevoke() / isRevoked(). The cache accepts up to 10min eventual
consistency across instances — webchat is low-volume, and a fresh node
sees revocations immediately on cold cache. DB remains source of truth.
WebChatController.verifyVisitorToken becomes an instance method that chains
verifyVisitorTokenSignature (static, HMAC + exp) with isRevoked (instance,
DB + cache). All 9 management endpoints now check revocation transitively.
Admin endpoint: POST /api/v1/admin/webchat/revoked-visitor (and DELETE to
un-revoke). Mounted under /api/v1/admin/** so it requires a MateClaw JWT
— visitors can't reach it. Records an audit row (action=webchat.revoke-
visitor, resourceType=CHANNEL) via AuditEventService.
Tests:
- WebChatTokenRevocationTest (@SpringBootTest, 7 cases): revoke blocks
/sessions with 401, un-revoke restores, double-revoke idempotent,
expired token rejected even without revocation, /stream unaffected
(signature still verifies), admin endpoint inserts row + audit.
- WebChatVisitorTokenTest extended to 16 cases — added expired-token,
tampered-exp, and "differs when exp differs" coverage; existing
verify_* cases moved to verifyVisitorTokenSignature (the static half).
Regression: WebChatSchemaFieldsTest (5/5), WebChatCreateSessionTest (9/9),
WebChatSessionManagementTest (5/5), WebChatStopStreamTest (5/5).
Part of epic #355.
Schema foundations for the rest of the epic. Three additions:
1. mate_conversation.archived — INT default 0. Lets a visitor "soft-close"
a thread: stays on disk (history preserved, addressable, downloadable)
but excluded from default /sessions listing. Pinned/archived are
orthogonal; archive dominates (archived+pinned still hidden by default).
2. webchat_revoked_visitor — registry table consumed in PR 2 by the
WebChatTokenRevocationService. Unique on (channel_id, visitor_id, deleted)
so re-revoke is idempotent and deleted=1 un-revokes. Migration written
for all three DBs (h2 IF NOT EXISTS, MySQL INFORMATION_SCHEMA guard,
KingbaseES native IF NOT EXISTS) following the V147 pattern.
3. WebChatSessionView gains pinned/archived/streamStatus so the visitor-
side listing surfaces the same state the admin console sees. Closes
the "field exposure" gap from the epic.
loadVisitorSessions gains an includeArchived flag (default false) — the
default hides archived threads and excludes them from the empty-session
quota, since the visitor already declared they're done with them.
GET /sessions and GET /sessions/page thread an `includeArchived=true`
query param through.
Tests (WebChatSchemaFieldsTest, @SpringBootTest + H2 + V148):
- revoked-visitor table is queryable
- archived column is read/write
- view exposes pinned/archived/streamStatus
- archived hidden by default, visible with includeArchived=true
- archived empty threads don't saturate the 5-thread quota
Regression: WebChatCreateSessionTest (9/9), WebChatSessionManagementTest
(5/5) — both updated for the new includeArchived param.
Part of epic #355.
Until now webchat had no way to actually interrupt a running stream —
ChatController's /api/v1/chat/{id}/stop was technically permitAll'd but
silently no-op'd on webchat streams because WebChatController.chatStream
dropped the subscribe() return value, so ChatStreamTracker.requestStop
had no Disposable to dispose. Visitors could only "stop" client-side by
closing the SSE connection; the server-side LLM call kept running,
burning tokens and firing any side-effecting tools to completion.
Two changes (issue #353):
1. WebChatController.chatStream: keep the Disposable and register it
with streamTracker.setDisposable, mirroring ChatController#chatStream
line 495. Now requestStop actually disposes the Flux.
2. New endpoint POST /api/v1/channels/webchat/sessions/stop:
- Auth mirrors the other session-management endpoints: X-MC-Key +
X-MC-Visitor-Token + ownsConversation (404 on unknown sessionId,
so callers can't probe the namespace).
- Returns {stopped: true|false}; false means no active stream
(idempotent, not an error).
- No approval sweep — webchat has no MateClaw username and exposes
no approval UI today; defer until that surfaces.
WebChatStopStreamTest (@SpringBootTest, H2, V147) — 5 cases:
- stopActiveStream registers a real Flux.never() Disposable on the
tracker and asserts both stopped=true AND disposable.isDisposed(),
proving the chatStream wiring change is what makes the endpoint work.
- noActiveStreamReturnsFalse — idempotent path.
- bad token / bad API key → 401.
- unknown sessionId → 404.
Complements the implicit getOrCreate in /stream: lets a caller pre-create
an empty thread (message_count = 0) and receive sessionId / conversationId /
visitorToken up front, then decide when to send the first message via
/stream. Mirrors how downstream CRM/ticketing systems model "create the
conversation object first, message later".
Auth is the visitor's first touch — only X-MC-Key is required (no
X-MC-Visitor-Token, which the visitor can't have yet); the server signs
and returns a fresh visitorToken the caller must echo back on subsequent
GET/PUT/DELETE.
Behavior (issue #351):
- Idempotent on sessionId collision → returns the existing thread 200,
does NOT clobber title.
- Empty-session quota ≤ 5 per (channel, visitor); 409 with a clear
message when exceeded. Existing rows are exempt (re-create is idempotent).
- Caller-supplied title (1-100 chars) is persisted; absent title leaves
the default "新对话" so the first /stream user message still derives
it. getOrCreateWebchatConversation now accepts an optional title and
only writes it on insert (existing rows untouched).
- agentId override mirrors /stream's workspace check.
ConversationService.getOrCreateWebchatConversation gains a title-aware
overload; the original 5-arg signature delegates with title = null.
End-to-end coverage in WebChatCreateSessionTest (@SpringBootTest, H2
with V147 migration): happy path, caller-title survives first user
message, default-title still derived, idempotent collision, quota 409,
bad API key 401, illegal sessionId/title 400, listed after creation.
Two webchat hardening fixes:
- Session listing no longer pulls every system-owned conversation into
memory. listSessions/pageSessions went through listConversations(owner)
whose `username IN (owner, system)` loaded all IM/cron rows just to show
one visitor's handful of threads. New listWebchatConversations(username)
queries only the visitor's own rows; the channel prefix is matched
in-memory with a literal startsWith (so a '_'/'%' in the api key's first
8 chars can't act as a LIKE wildcard).
- Upload now enforces a per-conversation quota (file count + total bytes,
both configurable) so a visitor can't fill the disk with many
individually-under-cap files. Pairs with the existing staging TTL sweep.
When webchat:<key8>:<visitorId>:<sessionId> exceeds 64 chars the
conversationId folds visitorId+sessionId into an unrecoverable hash, so
the thread fell outside listSessions' conversationId-prefix filter and its
sessionId could not be recovered — the thread was invisible and
unaddressable (common with a UUID visitorId + a >10-char sessionId).
Persist the sessionId on creation (new nullable webchat_session_id column)
and enumerate by username + channel prefix (webchat:<key8>:), which also
matches the hashed form. sessionId is read from the column, falling back to
parsing the conversationId only for legacy rows.
Adds a @SpringBootTest covering listing (incl. the hashed thread), message
pagination, session paging/search, rename, and token rejection end-to-end.
Refs matevip/mateclaw#346
Bring the webchat visitor session API closer to the admin console's:
- GET /sessions/messages gains beforeId + limit. With a limit it returns
{messages, hasMore} (latest N, then pull-up for older) using the
external path-stripped view; without it, the full list as before.
- GET /sessions/page paginates + keyword-searches a visitor's threads
(in-memory: a visitor's thread set is bounded to its own namespace).
- PUT /sessions/title renames a thread (1-100 chars).
All keep the webchat auth model (API key + visitor token, server-derived
conversationId, ownership guard). Message views go through the shared
toExternalMessageViews helper so the paginated path is sanitized too.
Refs matevip/mateclaw#346
WebChat had no file support: the /stream body carried only text, and
agent-produced files had no visitor-reachable download path (the JWT
/chat/files endpoint is unreachable for API-key visitors).
Add webchat-authenticated file transfer, reusing MessageContentPart +
the existing upload dir + agent multimodal injection:
- WebChatFileService: validate (size cap, extension whitelist, filename
sanitize), store under the conversation's upload dir, stage by opaque
fileId, traversal-safe resolve. Untrusted-uploader hardening lives here.
- POST /upload (multipart) and GET /files, both authed by API key +
visitor token with a server-derived conversationId (never client paths).
Downloads send non-images as attachment + X-Content-Type-Options:nosniff.
- /stream gains attachmentIds; the server resolves each id from the
staging registry (client metadata is never trusted), builds parts, and
persists them on the user message so the agent's multimodal/file tools
pick them up from history — same path as the JWT web chat.
- Strip server-side file paths from the visitor-facing message view
(listMessageViewsExternal + includePath flag) so the filesystem layout
is not disclosed.
Refs matevip/mateclaw#342
The permitAll comment claimed a 10-minute TTL, but GeneratedFileCache.TTL
is 7 days. The stale figure could mislead future security reasoning about
how long an unauthenticated capability URL stays live. Align the comment
with the actual value; the unguessable UUID remains the access guard.
Refs matevip/mateclaw#344
WebChat conversations are owned by an external visitor principal
(webchat:<visitorId>) so each visitor's threads stay isolated for the
self-service session API. But the console list/page/owner-check only
recognized the current user + system owners, so these conversations were
invisible in the sidebar and the Sessions page — and would 403 on open
even if surfaced.
Treat webchat: owners like system owners for the console: include them in
the lenient list/page queries and in isConversationOwner. The strict
listConversations overload (used by the visitor self-service path) is
unchanged, so a visitor's own access is not widened.
Refs matevip/mateclaw#340
The Web/API (webchat) onboarding wizard marked api_key as required while
it is also readOnly and platform-generated on save. The readOnly field
could never be filled during creation, so canSubmitConfig never passed
and "Continue" stayed disabled.
Exclude readOnly fields from the wizard's required/optional field sets so
they neither gate "Continue" nor render as fillable inputs. The api_key
still appears as required + readOnly in the edit modal once a value
exists.
Refs matevip/mateclaw#338
Turn a single natural-language requirement into a ready-to-review
employee: the model proposes name, persona, runtime type and a
validated set of skills/tools/knowledge base, which the user confirms
or tweaks before the agent is created.
- backend: POST /api/v1/agents/generate builds a draft from the
workspace's real capability catalog; every suggested tool/skill/KB is
re-validated against the catalog so nothing hallucinated is offered
- frontend: 3-step wizard at /agents/create reusing the existing
create + binding endpoints; reusable capability picker shows selected
items as compact chips with an on-demand searchable catalog
Add an opt-in named-entity extraction pass so the wiki knowledge graph
captures fine-grained entities (people, organizations, locations, ...)
and their relations, not just page-level link relations.
- new tables mate_wiki_entity / _mention / _relation (h2/mysql/kingbase)
- structured LLM extraction per chunk with entity resolution
(normalized-key dedup + embedding near-merge), mention/relation
persistence and page linking via chunk citations
- per-KB opt-in toggle (off by default); async dispatch after embedding
- read API: entity list, KB graph, entity ego-graph, manual extract
- UI: entity-layer toggle in the graph view + KB config toggle
- replace inline fully-qualified class names with imports in WikiProcessingService
Closes#336
Always-on memory (structured user/feedback blocks, PROFILE.md, MEMORY.md) is injected into every system prompt but only ever grew, inflating per-turn context over time. This adds deterministic size control across all always-on sources:
- Injection budget: cap the always-on structured block by total chars and per-type entry count, keeping the most-recently-updated entries (LRU by Updated date) and disclosing how many were omitted
- Nightly consolidation: a dedicated scheduled pass merges duplicate/stale user & feedback entries via the LLM, preserving each entry's original Updated date; runs per owner bucket (shared + personal) with a per-run cap and a never-grow safety guard
- File ceilings: deterministic backstop truncates PROFILE.md / MEMORY.md at a section boundary when a rewrite overruns its budget
- Manual trigger endpoint for the consolidation maintenance task
All knobs under mate.memory.*; covered by unit tests.
- goal: continue (not skip) on max-iterations and evidence-insufficient turns.
A max-iterations turn grants a fresh iteration budget ("hard continuation"),
bounded per run and sized into the graph recursion ceiling, so a task too big
for one budget keeps going instead of stalling until the next user message.
- plan-execute: re-plan the remaining work on a step exception, and on a
signature-based stall (repeated failures / identical results / no usable
result) instead of advancing dependent steps with junk; bounded by a per-run
re-plan cap, with a graduated change-strategy nudge before the hard stop.
- plan-execute: auto-derive a goal from a genuine multi-step plan, seeding the
acceptance criteria from the plan steps, so the goal subsystem engages without
the model calling setGoal; broadcast goal_created so the UI hydrates.
- react: refund the iteration for setup-only rounds (load_skill / enable_tool)
so a tight budget is not eaten by the load-then-use two-step.
- ui: re-fetch the active goal when a turn finishes so a goal created or mutated
mid-conversation surfaces without depending on an SSE event.
- streaming: make retry backoff / total-time budget instance fields with a
test-only seam; clarify that the wall-clock budget (not max-retries) bounds a
sustained SERVER_ERROR loop to ~8 attempts, fixing the slow/flaky retry test.
The knowledge-base trust verification recorded wiki citations from every
non-readFile tool response by sniffing its JSON for a top-level title /
pages / chunks field. Tools like getGoalStatus return a top-level title,
which falsely populated the citation set and then forced [n] citations on
the final answer (otherwise flagged EVIDENCE_INSUFFICIENT). Gate citation
mining on the wiki_* tool name instead.
Likewise, the grounded answer contract (cite-or-refuse) was appended to
every ReasoningNode call unconditionally, degrading general agents that
have no knowledge base. Append it only when the agent has a wiki_* tool
bound, scoping the strict regime to KB-grounded scenarios.
Adds a regression test asserting a non-wiki tool with a top-level title
creates no wiki citations.
- rescan: keep the KB id as a string end to end so the 19-digit snowflake id
isn't truncated past Number.MAX_SAFE_INTEGER (rescan no longer 404s)
- lint: resolve [[...]] targets against page slugs AND titles like the viewer,
so a title reference to an existing page is no longer reported broken
- ingest: derive the slug deterministically from the title (no inconsistent
romanization), auto-recompute broken links once a KB finishes importing,
and reconcile dangling [[concept]] links — redirect to the covering page via
declared aliases, or demote to plain text when uncovered
- add the page aliases column migration for h2 / mysql / kingbase
- add useStreamingMarkdown: cap mid-stream markdown re-render to ~140ms,
full-fidelity render once the segment completes
- skip code-block language auto-detection while streaming (escaped plain
text), restore full highlighting on the final render
- defer echarts/mermaid blocks to a lightweight loading placeholder while
streaming so their parsers never run on truncated source
- bypass the render cache for streaming-mode output
- wire into ContentSegment and MessageBubble (content + thinking)
Surface the connected database product as a subtle chip in the Dashboard
header. SystemHealthService now reports a database label on /system/health
(reused by the front-end — no extra request), derived from a new
DatabaseBootstrapRunner.getDatabaseLabel() that reads the JDBC product name
once and normalizes it to a canonical label (MySQL / MariaDB / PostgreSQL /
H2, and 人大金仓 for the KingbaseES family), collapsing driver version noise.
Add ErrorClassificationTest regression cases for the AI-gateway retry
hardening: 5xx-before-4xx ordering (a proxy 502 whose body says
"bad request" stays retryable), Chinese / numeric provider billing
patterns, and DNS / TLS infrastructure-fatal detection.
Fix the cert-trust pattern while adding its test: Java's ValidatorException
emits "PKIX path building failed" with an uppercase PKIX and the error
chain is not lower-cased, so the previous lowercase pattern never matched
— an untrusted/expired cert chain fell through to the retryable
SERVER_ERROR bucket and was retried in vain instead of failing over.
The blanket SMALLINT->BOOLEAN flag-column conversion over-reached: six wiki
columns map to Integer (1/0) entity fields, not Boolean. On vanilla PostgreSQL,
reading a BOOLEAN into a JDBC int throws 'Bad value for type int : f', breaking
every wiki KB list / SSE chat. Revert only those six back to SMALLINT (V133/V134/
V135/V136/V146 in the PostgreSQL-family tree) with guard comments; genuine
Boolean-entity columns stay BOOLEAN.
The disk fallback's catch block logged at debug, so a failed scan
silently dropped recovered files — reproducing the same 'bot can't see
the file' symptom the fallback was added to fix. Promote to warn with
the full stack trace, matching cacheRecentFile's logging.
openAiCompatibleClientBuilder() was creating a new java.net.http.HttpClient
per request. Each instance spawns a selector thread and connection pool that
are never closed, exhausting the OS thread limit under frequent model-test
calls (e.g. DeepSeek provider).
Elevate the HttpClient to a static singleton so all OpenAI-compatible
provider requests share one connection pool and one selector thread.
Closesmatevip/mateclaw#328
loadRecentFilesFromDisk now filters out files older than RECENT_FILE_TTL_MINUTES
(60 min) so the disk fallback matches the Caffeine cache TTL and does not inject
stale attachments into future conversations.
Testability refactoring:
- recentFileCache: private → package-private (tests can seed the cache directly)
- chatUploadsRoot: new package-private Path field (tests redirect to @TempDir)
- loadRecentFilesFromDisk: add (Path dir, long cutoffMs) package-private overload;
private (String) wrapper delegates to it
- injectRecentFiles: private → package-private
New test class FeishuRecentFileCacheTest (14 cases):
- loadRecentFilesFromDisk: non-existent dir, empty dir, fresh files sorted
newest-first, stale files excluded by TTL, mixed fresh+stale, >5 files capped,
timestamp-prefix stripping, MIME guessing from extension
- injectRecentFiles: Caffeine cache hit, cache-miss disk fallback, empty disk,
duplicate-path dedup, image vs file part typing, null textContent guard
Relates to #325
The per-chat recent file cache (Caffeine, 60 min TTL) is purely
in-memory. After a process restart, GC eviction, or TTL expiry the
cache is empty, but the staged copies under data/chat-uploads/ survive
on disk. A follow-up text message that should have seen the cached file
instead found nothing — the bot replied as if no file was ever sent.
Changes:
- injectRecentFiles(): fall back to scanning data/chat-uploads/{id}/
when the Caffeine cache misses, sorted by last-modified time, capped
at RECENT_FILE_MAX_PER_CHAT (5).
- cacheRecentFile(): promote catch log from debug → warn with full
stack trace so silent download failures are visible in production
logs. Add entry-level info log for correlation.
- New helper loadRecentFilesFromDisk() + guessContentType().
Closes#325
Relates to #201
The KingbaseES change removed usingDbTime() unconditionally, which made
every deployment (MySQL/H2/PostgreSQL) fall back to app-server time for
distributed lock timing — reintroducing node clock-drift risk in
multi-instance setups. Re-enable usingDbTime() for databases in ShedLock's
built-in dialect map and skip it only for KingbaseES, which is not covered
and would otherwise throw at lock acquisition.
The KingbaseES JDBC driver is not on Maven Central; declaring it as a
required runtime dependency broke the default build for anyone without
the proprietary jar. Move it into an opt-in `kingbase` Maven profile
(build with `mvn package -Pkingbase`). No Java code imports the driver
classes — it is loaded at runtime via driver-class-name only, so the
default build no longer needs it.
Also drop `mateclaw.browser.ssrf-check-enabled: false` from the default
application.yml: the code default is true, and disabling the SSRF guard
globally is unrelated to KingbaseES support.
Add KingbaseES support as an opt-in profile: dedicated migration tree, bilingual seed data, runtime DbType detection (KINGBASE_ES / POSTGRE_SQL), and JDBC URL handling in the datasource manager.
The raw-materials surface was renamed to "Sources" when upload, paste,
directory scan and per-KB auto-sync were unified into one tab. The read-only
viewers' reading-toggle segment still carried the old "Raw materials" label,
so managers saw "Sources" while read-only viewers saw "Raw materials" for the
same panel. Point the segment at the same i18n key for a consistent name.
* feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync
The raw-material directory scan and the Advanced "source watcher" sub-tab were
the same engine (same kb.sourceDirectory, same WikiDirectoryScanService) split
across two surfaces with two editable directory inputs. Merge them into one
"Sources" tab (upload / paste / directory manual scan + auto-sync toggle +
the raw-material list) and drop the watcher sub-tab from Advanced.
Auto-sync is now per-KB opt-in: a new watcher_enabled column (V146) gates the
periodic scan per knowledge base. The server-global mate.wiki.watcher-enabled
stays as an ops master switch — a KB is auto-scanned only when both are on
(AND). Manual scans are unaffected. Scan interval stays global for now
(tracked separately).
Closesmatevip/mateclaw#314
* docs(wiki): document source-watcher global switch env vars
Expose MATE_WIKI_WATCHER_ENABLED / MATE_WIKI_WATCHER_INTERVAL_MS as
explicit placeholders in application-mysql.yml, .env.example and
docker-compose.yml, mirroring MATE_WIKI_ALLOWED_SOURCE_ROOTS. Notes the
AND semantics (global ops gate + per-KB toggle) so operators know the
global switch alone is not sufficient.
The KB workspace split into a reading view (pages + graph) and a manage view
(raw materials, config, transformations, advanced, recent-activity snapshot)
gated behind manage:wiki. That moved the raw-materials and recent-activity
surfaces — which read-only viewers (view:wiki without manage:wiki) could
previously browse — entirely behind the management gate, silently dropping
their access.
Re-surface both in the reading-view segmented toggle for viewers who lack a
management view. Managers keep the focused pages/graph toggle and still reach
these surfaces through the management view, so nothing is duplicated for them.
The content panels already render by activeTab, so this only widens the
reading toggle and the activeTab/readingTab types.
- Fail closed to a global fallback sandbox root when a conversation has no
per-workspace base path, instead of leaving file/shell tools unconstrained
- Refuse shell commands that delete the workspace root directory itself
- Block workspace-boundary escapes at the policy layer before the approval
prompt, not only at execution time
- Approval bar now shows the actual command / target path being approved
The pageType profile could only be edited as a raw JSON string. Add a
structured form editor (default) that builds the profile without writing JSON:
profile-level settings (fallbackType / allowAdditionalFields), an ordered page
type list (add via a small wizard, remove, reorder), and a per-type form for
label / description / layer / field schema, with stage prompts (route/create/
merge) and the markdown template folded into an "advanced" section that carries
inline descriptions and examples. A form/JSON toggle keeps the JSON view as the
final review surface; serialization preserves unknown keys for forward-compat,
and save/validate/reset reuse the existing endpoints (no backend change).
Closesmatevip/mateclaw#310
The KB workspace previously stacked all seven surfaces in one tab strip.
Split them by intent: a gear on each library card opens the management view
(raw materials, config, transformations, advanced, hot cache), while clicking
the card body opens the reading view (pages + graph). The reading view drives
page/graph via a header segmented control with the page tree shown only for
the page viewer; the two views share loaded data and toggle without refetch.
Closesmatevip/mateclaw#308
The V85 seed shipped a dev/test placeholder (sse + http://localhost:8085/sse
+ "Bearer ${CKJIA_MCP_KEY}") that can never connect, so the 参考价 price-
comparison skill was unusable until an admin hand-edited the row.
Add V144 (h2 + mysql) to rewrite the seed to the real CKJIA SaaS endpoint:
streamable_http + https://m.ckjia.com/api/ai/mcp, no auth header, connect/read
timeouts raised to 60s. Conditional on the row still carrying the dev
placeholder URL, so an admin who already pointed it at a private deployment is
left untouched; idempotent and leaves `enabled` opt-in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes#289 — after an MCP server (re)connects, chat queries kept replying
"from memory" instead of calling MCP tools.
Root cause: agents snapshot their tool set at build time and are cached in
AgentService.agentInstances, but MCP server lifecycle changes never
invalidated that cache (unlike model-config / tool-guard changes which do).
A stale, tool-less agent graph survived until process restart.
Changes:
- Add McpServerChangedEvent; McpServerService publishes it on connect /
disconnect / reconnect / delete / (re)connect-failure / batch refresh /
startup init. AgentService listens and calls refreshAllAgents(), so the
next turn rebuilds against the live MCP tool set. Also closes the boot
race where the web server accepts requests before the @Order(200) MCP
init runner finishes.
- Make create/update/toggle connect asynchronously on a dedicated pool
("mcp-connect") so a slow/unreachable server can no longer freeze the
admin request; status returns immediately as "connecting".
- UI: render the new "connecting" status (pulsing amber dot), show a
friendly "connecting in background" toast, and poll until the status
settles (window widened to ~40s to outlast the default connect timeout).
- UI: MCP config modal no longer closes on outside/backdrop click — only
the × and Cancel buttons close it, so an accidental click can't discard
unsaved config.
Verified E2E: ckjia-shopping (参考价) MCP server connected at runtime with
no backend restart; the cached 通用助手 agent immediately enabled and called
ckjia_shopping_recommend, returning real product cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The webchat conversationId (webchat:<key8>:<visitorId>[:<sessionId>]) and the
derived username (webchat:<visitorId>) are written to VARCHAR(64) columns, but
visitorId had no validation and sessionId allows 64 chars — so a long visitorId,
or a legitimate 64-char sessionId, overflows the column and the getOrCreateConversation
INSERT throws (500 on /stream). Validate visitorId (charset + blank->UUID) and
fold the variable part into a stable hash when the derived id/username would
exceed 64 chars, keeping short ids byte-identical (backward compatible). Also
make listSessions filter on exact owner username, not just the conversationId
prefix, so system-owned rows can never leak via a crafted visitorId. Adds
boundary regression tests.
Add per-visitor session management for the WebChat Web/API access mode:
list a visitor's conversation threads, fetch a thread's messages, and
delete a thread.
Authorization: visitorId is a client-asserted request param, so it cannot
be trusted on its own — deriving conversationId from it and then checking
ownership against it is tautological (any caller passes). Instead, /stream
issues a per-visitor token = HMAC-SHA256(jwtSecret, channelId:visitorId),
returned in the meta event; the management endpoints require it back via
the X-MC-Visitor-Token header and verify it in constant time. The signing
secret is server-only (unlike the public channel API key) and the channelId
in the payload makes tokens non-portable across channels.
Includes regression tests for token issuance/verification semantics
(forged visitorId rejected, cross-visitor and cross-channel tokens rejected,
tampered tokens rejected).
The WebChat stream endpoint resolves agentId only when the
(visitorId + sessionId) conversation is first created; later requests
with a different agentId reuse the existing conversation's agent and
silently ignore the new value. Document this on WebChatRequest.agentId
so integrators know to use a new sessionId to reach another agent.
The multimodal sidecar selector change made resolveSidecar honour an explicit
sidecar selection even when the built-in capability heuristics don't recognize
the model (it now logs a diagnostic and returns the model instead of rejecting
to NONE). The test still asserted the old reject->NONE path. Update it to assert
SIDECAR and the honoured model, matching the current production behavior.
The vision/video sidecar dropdown filtered candidates through the built-in
capability heuristics, so provider-compatible models whose custom names match
no known prefix (and carry no declared modalities) were hidden and could not be
selected as a sidecar — even when they natively support the modality.
- listByType now returns every enabled chat model, annotating each row with a
transient modalityCapable flag and sorting known-capable rows first, instead
of hard-filtering recognized models only.
- MultimodalRouter honours an explicitly configured sidecar model instead of
dropping it when the heuristics don't recognize it; a wrong pick degrades
gracefully through the caption path rather than silently disabling routing.
- ModelPicker gains an optional capability badge; the sidecar UI tags
recognized vision models while keeping every enabled model selectable.
Both failed on dev independently of the recent merges (confirmed against a
pre-merge baseline):
- MemorySummarizationStructuredRoutingTest reflected applyStructuredEntries by
its old (Long, JsonNode) signature; owner-isolation added a trailing ownerKey
param. Update the reflective lookup to (Long, JsonNode, String) and the
remember() verifications to the 6-arg overload.
- ToolGuardCardHandlerTest still asserted the old 'system-owned pending accepts
any clicker' behavior, but the handler now rejects a group click on a
system/cron-owned approval fail-closed (no human requester to match), routing
it to the admin console. Assert no synthetic injection + the unauthorized card
render instead.
Decoupling the channel-message event bridge onto an @Async listener means the
downstream workflow run is produced off the event-publishing thread. The test
read the run table synchronously right after publishEvent, racing the listener
— the positive cases failed and the negative cases passed for the wrong reason.
Poll briefly for the run (positive) / give the listener time then assert none
(negative) so the test reflects the async dispatch semantics.
The mention alias-learning fed every identifier of every mention in a
delivery into the per-chat alias cache. A single delivery of "@bot @alice"
matched the bot by its global id and then learned alice's openId as a bot
alias, so every later "@alice" message was misdetected as @bot and the agent
replied to messages never addressed to it.
Only single-mention deliveries are unambiguous bot identities, so restrict
alias learning to them — a multi-mention delivery mixes the bot with
co-mentioned humans, and Feishu's dual-delivery alias form is itself a single
mention, so this is safe and keeps the learning feature working. Also cap the
per-chat alias set size. Adds a [bot, human] co-mention regression test.
Rename the webchat channel from "embed widget" to "Web / API access" (key
unchanged, docs/i18n only) and extend the backend SSE endpoint for pure
backend integration:
- WebChatRequest gains optional agentId (route one Key to multiple agents;
rejected unless the agent shares the channel's workspace) and sessionId
(one visitor, multiple isolated threads).
- sessionId is validated ([A-Za-z0-9_-]{1,64}) and only composed into the
server-derived conversationId — raw conversationIds are never accepted, so
the key+visitor namespace still bounds every thread.
- A `meta` SSE event echoes the effective sessionId/conversationId at stream
start so callers can persist and re-address a thread.
- Memory stays attributed per visitor (api:<visitorId>), shared across that
visitor's sessions.
All new fields are optional; omitting them reproduces the prior behaviour
byte-for-byte. Refs matevip/mateclaw#295.
ChannelMessageEventBridge.onChannelMessage() ran synchronously on the Lark
SDK WebSocket dispatch thread; when the DB pool was saturated its ingest
query blocked that thread and subsequent messages were silently dropped.
- @Async moves trigger ingest onto the (vthread + SecurityContext-propagating)
async executor, freeing the WS dispatch thread.
- Add a 10s timeout to the refreshTenantAccessToken() HTTP request, which
previously had none.
Scope narrowed per review to only fix#208: the @mention alias learning and
session-id changes are dropped (to be raised as separate PRs), and dev's
existing message dedup is left untouched.
Closes#208
Add an execute_code built-in tool that runs python/bash/node code the agent
writes on the fly, so a documentation-only skill (a SKILL.md with no bundled
scripts) can be acted on. Scoped runs inject the skill's secrets and run in
the skill directory; otherwise a private scratch directory is used. Host
secret env vars are scrubbed from the subprocess. execute_code is an
agent-wide capability, registered in the tool catalog (V143), and screened
by the tool guard with a dedicated set of destructive-pattern rules.
Tests cover python/bash/node execution, scratch-dir fallback, env scrubbing,
argument decoding, and guard gating.
- WikiPageTypeProfile: normalise pageType keys to lowercase on set, so a
user-authored profile with an uppercase key still matches the
case-insensitive hasPageType/get lookups.
- WikiDirectoryScanService: normalise the symlink-resolved glob base to
forward slashes so directory-scan globs work on Windows paths.
- Agents roster tag filter: keep selected tags that no longer exist on any
agent visible and deselectable (and show the filter bar when only such
orphan selections remain) instead of silently filtering with no way to clear.
A second POST to /reclassify on the same KB spawned an independent pass over
the same pages, doubling LLM spend and racing the first pass's page-type
writes. Add a per-KB in-flight guard that rejects a concurrent run with a
friendly message (409 via R.fail rather than a generic 500), released in a
finally once the async pass completes. Also count and broadcast per-page
failures so an all-failing run is visible instead of reporting changed=0, and
type the api modelId param as string|number per the snowflake ID convention.
The resilient connect() override started the child process into a local
variable and never set the parent's private process field, so the parent's
closeGracefully() logged "Process not started" and left the child running on
every reconnect/disable. Retain the started process and override
closeGracefully() to destroy it and interrupt the spawned I/O threads.
Also make the inboundSink/errorSink reflection fail loud (the transport is
useless without them — a silent null yielded a connected transport that timed
out on every call), force UTF-8 on the stdio reader/writer, honour a
cooperative closing flag in the reader loops, and drop duplicate PATH entries.
Add a backfill path so pages created before a KB's pageType profile changed
can be migrated into newly-added types. A per-page classify-only LLM call
(title + summary in, single page_type out) is normalised through the profile
and written back via a partial update that never touches page content.
Exposed as POST /knowledge-bases/{id}/reclassify (admin) and a "re-classify
existing pages" action in the Wiki advanced panel.
Wiki page classification was only profile-aware in the main ingest pipeline.
Transformation outputs hard-coded "synthesis", agent-created pages were left
untyped, and the frontend hard-coded the built-in ten types for ordering,
colouring and labels — so custom/synthesis types sank to the bottom, rendered
grey and showed raw keys.
Backend:
- Add nullable target_page_type column to mate_wiki_transformation (V142,
mysql + h2) plus the entity field and CRUD normalization (blank = use
profile fallbackType; membership validated at save time, not edit time).
- Route transformation single-run + KB-aggregate page saves through
WikiPageTypeProfileService.normalizePageType so output joins the KB
classification; agent wiki_create_page now lands on the profile fallbackType
instead of an untyped page.
Frontend:
- Load + parse the KB pageType profile into the wiki store (order, labels,
fallbackType) on KB select / refresh.
- New useWikiPageType composable: profile-driven label (3-tier fallback) and
colour (built-in fixed + deterministic hash palette for custom types).
- Sidebar grouping order, graph colouring, node panel, graph filter and the
page header badge now follow the profile; transformation editor gains a
target-type dropdown sourced from the profile when output target is a page.
Refs #292
Move the AGENTS.md section editing into a click-to-open modal so the Basic
tab no longer expands the full document inline. Present the entry as a
settings-style row (title + description on the left, a "manage" button on the
right) instead of stacked label/hint/button.
Add optional move-up/move-down controls to MemorySection (off by default, so
MemoryBrowser is unaffected) and wire reordering in AgentGuideEditor: adjacent
sections swap and the whole file is re-saved, with a synthetic preamble pinned
to the top.
Add a friendly AGENTS.md section editor inside the edit-agent modal's
"高级" (Advanced) collapsible, alongside the existing additional-instructions
field. New AgentGuideEditor reuses MemorySection cards, parses the file into
## sections, and saves whole-file via the workspace API (no backend change).
Empty files offer a "create" scaffold flow.
Reword the collapsible to a generic "高级" and add a role-clarifying note that
fixes the boundary: factory identity goes in the form fields, evolving
memory/rules go in the context files. Also fix the additional-instructions
textarea so it fills the form width.
Closesmatevip/mateclaw#290
Add 5-arg buildContextMessage overload that emits [system-context] Model:
for every origin (web/cron/IM). Legacy 3/4-arg overloads delegate to the
new one with null model args, keeping their output byte-identical.
Also fix pre-existing FeishuMentionTest compile error caused by removed
mentionMatchesAnyAlias/collectMentionIdentifiers methods.
A silent (no-op) cron run still makes a full LLM call, but the marker
message was persisted via the token-free saveMessage overload, so the
settings-page Token total (which aggregates MessageEntity token columns)
under-counted cron spend by exactly the silent runs.
Carry chatResult's prompt/completion tokens onto the marker message,
matching the non-silent branch. Closes#284.
The base directory is canonicalized via toRealPath before walking, so the
walked files carry the symlink-resolved prefix. The PathMatcher was built
from the literal pattern, so a symlinked base never matched and files were
silently dropped. Rebuild the glob against the resolved scan root, escaping
glob metacharacters in the base so a real directory name containing */?/{}/[]
is treated literally.
The upstream StdioClientTransport breaks out of its inbound read loop on
any JSON parse error, permanently killing the reader thread. Some MCP
servers write non-JSON debug output to stdout (e.g.
"=== Document parser messages ==="), which triggers this and causes all
subsequent valid JSON-RPC responses to be lost → 30s timeout → agent stuck.
Override connect() in CwdAwareStdioClientTransport to skip non-JSON lines
(log at DEBUG) instead of breaking, keeping the inbound thread alive.
Closes#226
Adds a tag filter bar to the agent roster, orthogonal to the existing
type/status tabs. Multi-select narrows by intersection (an agent must
carry every selected tag). Tags also render as clickable chips on each
agent card. The bar is hidden when no agent has tags.
Tags are de-duped per agent and ordered by usage frequency. When a
workspace has more than 12 distinct tags, only the top-N show inline and
a search box filters the rest; selected tags stay visible regardless.
The #254 fix changed resolveConventionPath to {name}-{hashCode} and folded
hyphens to underscores, which re-pathed every existing skill (browser-cdp ->
browser_cdp-<hash>) with no migration, orphaning already-created workspaces and
breaking SkillWorkspaceManagerApplyBundleTest. Drop the hash suffix and keep
hyphens: the bare Unicode-preserving sanitized name already prevents the
non-ASCII collision (distinct CJK names map to distinct dirs) and leaves ASCII
kebab-case paths identical to the legacy scheme. Add path regression tests.
Skills with non-ASCII (e.g. Chinese) names collapsed to underscores in the workspace path, so two such skills resolved to the same directory and overwrote each other. Preserve Unicode letters/digits when sanitizing the path so distinct names map to distinct directories. Also write SKILL.md with CREATE_NEW to avoid a TOCTOU race on concurrent uploads, and mount the skills workspace as a named Docker volume so packages survive container restarts.
Closes#254
updateTextContentFromScan / updateBinaryFileFromScan are only reached via
self-invocation from the ingest* methods, so the proxy-based @Transactional
never applied. Each runs a single atomic updateById; remove the misleading
annotation and document why.
Directory-scan ingestion deduped only by content hash, so when a file at a known path changed, the new hash missed the existing raw and a second row was inserted for the same source_path — both rows then generated wiki pages, accumulating duplicates. Make source path the primary dedup key: same path + same hash skips, same path + changed hash updates the existing raw in place (reset to pending, re-process), falling back to the content-hash check only for genuine copies at new paths. Reprocessing reuses the same rawId, so deleteExclusiveBySourceRawId cleans the old pages before regeneration — no duplicate rows and no duplicate pages. findBySourcePath gains LIMIT 1 to tolerate pre-existing duplicates; docs/fix-duplicate-raws.sql remediates existing data.
Closes#271
Gate MarkdownNormalizer behind mate.agent.markdown-normalize-enabled (default
true) so operators can disable the rewrite verbatim if a normalization edge
case ever mangles a legitimate answer.
LLMs routinely emit malformed Markdown (missing heading spaces, glued `---`, unaligned table pipes) that prompt rules cannot reliably prevent. Add a zero-token, regex-only MarkdownNormalizer applied on the FinalAnswerNode convergence path before persistence / channel delivery. It is code-fence aware, idempotent, and conservative (em-dash `---`, `#5`-style refs, stray prose pipes are left untouched). RETURN_DIRECT verbatim output and approval-wait paths return earlier and are unaffected.
Closes#274
Same-message attachment reads failed on Feishu: the content part carried the sandbox-external ~/.mateclaw/media/ path, so read_file/extract_document_text were rejected by WorkspacePathGuard and ChatUploadResolver's basename fallback missed (media names {messageId}_{key} vs chat-uploads {millis}_{fileName}). cacheRecentFile already copies the attachment into the per-conversation data/chat-uploads/ dir; return that absolute path and stamp it onto the current message's image/file/audio/media parts so the path surfaced to the LLM is resolver-reachable. Reuses the existing copy — no extra I/O, no new state. Rich-text post multi-image and other channels sharing the media dir are noted as follow-ups.
Closes#278
Allow knowledge base source paths to be configured as a newline-separated
list of absolute paths or glob patterns rather than a single directory.
- Each non-blank, non-# line is treated as one path or glob pattern
- Plain paths (no wildcards) retain the existing recursive-scan behaviour
filtered by SUPPORTED_EXTENSIONS
- Glob patterns (e.g. /data/ocr/**/*.txt) walk from the fixed-prefix base
and apply Java's PathMatcher against each candidate's absolute path
- Patterns whose filename segment explicitly specifies an extension
(*.txt, *.{xlsx,csv}) skip the SUPPORTED_EXTENSIONS secondary filter,
respecting the user's explicit choice (key for OCR pipelines that
produce .txt output and should ignore the original PDF scans)
- Candidates collected across multiple patterns are deduplicated by
resolved absolute path so overlapping patterns don't double-count
- Symlink-escape check uses each pattern's own validated scan root
- parseSourcePatterns / extractBasePath moved into WikiSourcePathValidator
to eliminate a static circular reference between the two services
- Frontend watcher panel: single-line <input> replaced with <textarea>
supporting multiline editing; i18n updated with example patterns
- No DB schema change; fully backward-compatible with existing single-path
configs stored in sourceDirectory
Closes #(pending issue)
finishRunAndPublish updated the run row with status and finished_at but dropped the LLM token usage, so mate_cron_job_run.token_usage stayed NULL and the scheduler history always showed 0. Write the prompt+completion token total (already computed for the assistant message row) to the run row, guarded so non-LLM paths (reminder direct-push with a null ChatResult) leave the column untouched.
Closes#262
The skill meta-tools (listAvailableSkills, readSkillFile, listSkillFiles, load_skill, runSkillScript) queried the full skill catalog without checking the calling agent's bindings, so an agent scoped to a subset of skills could still read, load, or execute any skill via direct tool calls. Resolve the agent's bound skill ids from the tool context and filter/deny accordingly: a null binding set means no restriction (backward compatible), a non-null set (including empty) restricts access to that set — matching the system-prompt catalog filtering.
Closes#264
Surface directory-scan failures to the user via toast and render ScanResult.errors[]; expose MATE_WIKI_ALLOWED_SOURCE_ROOTS as a Docker env entry with blank-entry filtering in the path validator; set C.UTF-8 locale in the runtime image so non-ASCII file names decode correctly during scans.
Fixes#259
Add a single global-proxy switch that routes the backend's outbound traffic
through a configured HTTP/HTTPS/SOCKS proxy, for deployments that cannot reach
overseas APIs directly or must use a unified egress.
- ProxyManager installs the proxy via a default ProxySelector (honored by
java.net.http and HttpURLConnection), the proxy system properties, and a
--proxy-server arg for the browser tool; restores direct egress when
disabled. One switch covers LLM, web search, media generation, channels,
MCP and the browser.
- SOCKS applies to the HttpURLConnection-based egress only; the java.net.http
LLM/streaming path uses an HTTP proxy, and the UI states this.
- New Settings -> Network Proxy page: enable toggle, address, bypass list,
test-connection, and a coverage summary. Config stored as key/value in
mate_system_setting (no migration).
refs #109
Streaming chat ran render tools on an async thread with no bound request,
so download links lost their host and arrived without a domain. Resolve the
host on the request thread and carry it through ChatOrigin/ToolContext;
falls back to a configurable public-base-url, then a relative path.
Add the user-facing and API-reference docs that explain how an employee
can declare a primary knowledge base, what the runtime fallback chain
looks like, and how the binding is driven from the API.
agents.md (zh + en)
- New section "Knowledge base binding (per-agent primary KB)" right after
the tool-binding section, with a 1.5.0 New In badge
- Lays out the design intent explicitly: KBs stay workspace-shared, the
binding only chooses a default target, multiple agents may pick the
same KB as primary
- Documents the runtime resolution order used by wiki tools: explicit
kbName/kbId, agent.primaryKbId, most-recently-updated workspace KB
- Migration note about the legacy kb.agent_id to agent.primary_kb_id
backfill and the visibility change for anyone who relied on the old
one-to-one isolation
api.md (zh + en)
- Under Agents: document the primaryKbId field with the three-state PUT
semantics (omit / null / value) and example curl calls
- Under LLM Wiki: document GET /api/v1/wiki/knowledge-bases/bindable and
the explicit warning that PUT /api/v1/wiki/knowledge-bases/{id} no
longer processes the agentId field
Vite's dep pre-bundling runs esbuild's lite parser over .vue script blocks
to find imports. On esbuild 0.27.5 that parser interprets the
HTML-style sequence `<!--` inside a JS string/regex literal as the start
of a legacy line comment, which made it conflate two unrelated string
literals on lines 95 and 110 of MemoryBrowser.vue and report a fake
"Unterminated string literal" against a phantom line that doesn't exist
in the source.
Replacing the raw `<` with `\x3c` keeps the runtime behavior identical
(includes/regex match the same `<!-- user-edited` marker) but breaks the
HTML-comment heuristic so the scanner no longer chokes. The two call
sites — userEdited detection in parseSections and the strip regex in
stripMarker — both need the escape.
Repro: `pnpm dev` from a cold cache surfaces the error during dep scan,
runtime tests passed already because the actual JS parser handles the
strings fine.
PR #237 introduced V129__agent_primary_kb.sql while dev also has
V129__wiki_page_broken_links.sql shipped from the broken-link lint work.
Flyway rejects duplicate version numbers at startup, so the agent
primary_kb migration moves to V130 across both H2 and MySQL dialects.
No content change beyond the rename — the column add, index, and
backfill SQL are identical to the V129 originals from #237.
Agents now have a per-agent primary wiki KB stored on
mate_agent.primary_kb_id. KBs remain workspace-shared — selecting one in
the agent editor only chooses the default wiki target for that agent, it
does not change the KB's ownership or visibility.
Backend
- AgentEntity: add primary_kb_id field (FieldStrategy.ALWAYS so the UI
can clear it back to "no primary")
- AgentController#update: switch body to Map<String, Object> so we can
tell "field missing" apart from "explicit null" via containsKey, then
convertValue back to AgentEntity
- WikiKnowledgeBaseService:
- new resolvePrimaryKb(agentId): prefers agent.primary_kb_id when it
points to a workspace-visible KB; falls back to legacy
kb.agent_id marker, then to most-recently-updated workspace KB
- listByAgentId now returns the full workspace set (KBs are
workspace-shared under the new model)
- update(id, name, description) no longer touches agent_id
- WikiController: new GET /knowledge-bases/bindable for the UI picker;
PUT /knowledge-bases/{id} no longer reads agentId
- WikiKnowledgeBaseEntity: add FieldStrategy.ALWAYS on embeddingModelId
and configContent so explicit nulls actually unbind/clear instead of
being silently skipped by MyBatis-Plus's NOT_NULL default
- Migrations V129 (H2 + MySQL): add primary_kb_id column + index, backfill
from legacy kb.agent_id, MySQL uses INFORMATION_SCHEMA guard +
PREPARE/EXECUTE for idempotency
- WikiKnowledgeBaseServiceTest: 13 cases, all passing
Frontend
- Agents.vue: new "Knowledge Base" tab, radio-select bindable KBs
- API: listBindableKBs() + Agent.primaryKbId typed string | number | null
- IDs handled as strings throughout (Snowflake-safe)
- i18n keys for the new tab in zh-CN and en-US
The previous private-repo support inlined the access token into the
clone URL and then logged that URL on success — leaking the token to
log files, container stdout, and any IOException thrown when the clone
failed. The token also appeared in the process command line, visible
to anyone with shell access via `ps`.
Switch to git's GIT_CONFIG_COUNT/KEY/VALUE environment variables, which
inject `http.extraHeader: Authorization: Bearer <token>` into the child
process without ever touching argv or the repo URL. The URL stays
pristine, so the existing INFO log and error message are safe.
Other changes:
- Resolve token from `mateclaw.skill.github-token` property first, then
fall back to GITHUB_TOKEN env var. Keeps the original deployment
contract while letting admins manage the credential via configuration.
- Tighten the host check (prefix match on `https://github.com/` etc.)
so a crafted URL like `https://evil.com/?u=github.com/...` cannot
trick the fetcher into forwarding the token to a third party.
- Set GIT_TERMINAL_PROMPT=0 so a bad token fails fast instead of
blocking on an interactive password prompt.
stopWebSocket() only nullified the wsClient reference without calling
disconnect() on the SDK client. This left the old WebSocket connection's
pingLoop thread and ExecutorService running, leaking file descriptors
and threads on each reconnect. Over time, accumulated leaks prevented
new connections from being established, causing the Feishu channel to
silently stop receiving messages.
Fix: use reflection to access the SDK's protected `conn` field and call
close(1000) on the OkHttp WebSocket, triggering the SDK's onClosed →
disconnect() cleanup chain.
Note: oapi-sdk 2.7.1 adds a public close() method that would make this
reflection unnecessary. Consider upgrading as a follow-up.
Closes#220
IM channels (Feishu, DingTalk, WeCom, etc.) and the WebChat widget were
calling saveMessage without token usage parameters, causing promptTokens
and completionTokens to default to 0. This made the Token Statistics
module report significantly lower numbers than actual usage.
Root cause: the _usage_final event (containing promptTokens /
completionTokens) emitted by the agent graph at stream end was not being
captured in these paths, unlike ChatController's StreamAccumulator which
already handles it correctly.
Fix: capture _usage_final events in doOnNext handlers for:
- ChannelMessageRouter sync path (non-streaming IM adapters)
- ChannelMessageRouter streaming path (DingTalk, etc.)
- WebChatController SSE stream
Refs #214 (remaining String-API paths covered by follow-up).
Some OpenAI-compatible providers (LM Studio's built-in server, certain
strict-mode vLLM / SGLang deployments) reject 400 "System message must
be at the beginning" when SystemMessages appear after user / assistant
/ tool messages. The reasoning loop currently emits four SystemMessage
segments — main prompt at index 0, skill catalog inserted at index 1,
progress-ledger snapshot and stale-reminder appended at the end of
nonHistoryPrefix after the runtime-context UserMessage. The latter two
violate the strict shape, so conversations on LM Studio 400 on the
first turn (reported in #218).
Add MessageNormalizer: collects every SystemMessage in the outbound
prompt regardless of position, joins their text with a blank-line
separator, and emits a single SystemMessage at index 0. Non-system
messages keep their relative order, so AssistantMessage(tool_calls) ↔
ToolResponseMessage adjacency is preserved verbatim (required by strict
pair validators).
Wire it into doStreamCall as the first pre-egress step so every node
(reasoning, step-execution, summarizing, plan-generation, limit-exceeded)
inherits the fix without per-node changes, and any future node that
emits multiple SystemMessages stays compliant.
The transformation is semantically equivalent on permissive providers
(OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao, GLM) — the merged
token sequence matches what they would have seen across N SystemMessages
— and safe on non-OpenAI protocols (Anthropic, Vertex / Gemini), whose
adapters already extract SystemMessages into a top-level system field
and receive an identical payload.
Kill switch: -Dmateclaw.llm.message-normalizer.enabled=false reverts to
the prior behavior for emergency rollback.
Tests: 11 unit tests on MessageNormalizer cover empty / no-system /
canonical / mid-list / tail / blanks / tool-pair preservation / Prompt
option-reference preservation / kill switch. 1 wiring test pins the
call site in doStreamCall. Full vip.mate.agent.** suite (504 tests)
stays green.
Closes#218.
1. Relative parent traversal in shell commands (HIGH)
validateShellCommand only scanned absolute path tokens, so commands
like `cat ../mateclaw/CLAUDE.md`, `cd .. && cat foo`, or
`ln -sf ../bar breakout` had no absolute path to trip the check.
From a workspace cwd that's a real escape — `..` segments resolve
against the JVM cwd at file-tool time and reach anywhere the user
can read.
Add a second pass: any token containing `..` as a path segment is
resolved against the workspace root via root.resolve(token).
normalize(); reject when the result falls outside. In-workspace
traversal like `subdir/../sibling` normalizes back inside and
passes. Identifiers without slashes (e.g. version strings with
`1.2..3`) are not treated as paths.
2. Shell validation and process working directory used different
context sources (MEDIUM)
execute_shell_command validated with the explicit ToolContext, but
buildShellProcess called WorkspacePathGuard.getWorkingDirectory()
(no-arg), which only sees the ThreadLocal fallback. Today the
ToolExecutionExecutor sets both so the discrepancy is latent, but
a future direct Spring AI invocation passing only ToolContext would
validate against one basePath and exec against another. Thread ctx
through buildShellProcess and call getWorkingDirectory(ctx) so
validation and execution agree on a single source of truth.
3. Absolute agent override could disable workspace scoping (MEDIUM)
resolveAgentBasePath accepted an absolute override verbatim, even
when it pointed outside the workspace root. An admin (or any
account with agent-edit permission) could set workspaceBasePath="/"
or another team's repo and bypass workspace boundaries entirely.
When a workspace has its own basePath, require absolute overrides
to sit underneath it. The caller in build() catches the rejection,
logs WARN, and falls back to the workspace basePath so chat stays
available rather than crashing agent construction. When the
workspace has no basePath there's no boundary to enforce, so legacy
behavior is preserved.
Test coverage: WorkspacePathGuardShellTest grows from 17 to 23 (six
new cases for `cd ..`, relative parent traversal, relative symlink
escape, deeper traversal, in-workspace normalization, and the
identifier false-positive guard). AgentGraphBuilderBasePathResolutionTest
grows from 7 to 10 (three new cases for in-workspace absolute,
outside-workspace absolute rejection, and no-workspace legacy
behavior). All 45 sandbox-area tests pass with no regressions.
* feat(agent): optional agent-level workspace basePath override
Add workspaceBasePath field to AgentEntity that optionally overrides
the workspace-level basePath. When set, the agent uses its own directory;
when null, it inherits the workspace's basePath (existing behavior).
- AgentEntity: new workspaceBasePath field with ALWAYS update strategy
- AgentGraphBuilder: agent-level override takes priority over workspace
- Flyway migration V121 for H2 and MySQL
- UI: form input in basic tab with i18n (zh-CN, en-US)
* fix(agent): rename migration V121→V125 to avoid Flyway conflict with upstream
Upstream already has V121__tool_disclosure_tier.sql. Rename our
migration to V125 (next available after V124).
* fix(agent): make MySQL V125 migration idempotent
Use INFORMATION_SCHEMA check before ADD COLUMN to avoid
"Duplicate column name" error on re-deploy.
* feat(tool): add send_file tool for sending existing server files as IM attachments
Adds a new built-in tool that reads a file from the server and stashes it
in GeneratedFileCache so the channel adapter (Feishu, DingTalk, etc.)
automatically sends it as a native attachment. This fills the gap where
agents had no way to send existing server files to users — ReadFileTool
only reads text, and render tools only generate new files.
- New SendFileTool with path validation, MIME detection, 20MB limit
- Added "send_file" to tool allowlist in AgentBindingService
- Added i18n error messages (zh-CN + en-US)
* fix(tool): send_file returns URL in scrubber-detectable format
The previous JSON return format caused the LLM to reply with just
"status: sent" without echoing the /api/v1/files/generated/{id} URL.
GeneratedFileScrubber only scans the LLM's final text output, so the
file was never delivered as a native attachment.
Changed to match GeneratedFileLink's format: returns a markdown link
with explicit instructions for the LLM to echo the URL verbatim.
The Lark SDK throws HandlerNotFoundException for any event type without
a registered handler. This exception is caught internally by the SDK's
WebSocket client, which then sends a 500 response to the Feishu server.
The server may close the connection as a result, and the exception is
swallowed — never reaching the application layer.
Added empty handlers for all remaining IM event types:
- P2MessageReadV1 (read receipts)
- P2MessageRecalledV1 (message recall)
- P2ChatMemberBotDeletedV1 (bot removed from chat)
- P2ChatMemberUserAddedV1 / UserDeletedV1 / UserWithdrawnV1
- P2ChatUpdatedV1 (chat info update)
- P2ChatDisbandedV1 (chat disbanded)
- P2ChatAccessEventBotP2pChatEnteredV1 (bot entered p2p chat)
Also added explicit logback config for com.lark.oapi at WARN level
to ensure SDK internal errors are not silently filtered.
Refs: larksuite/oapi-sdk-java#185
Some providers (notably SiliconFlow) return "network connection error" in the response body when their backend is overloaded or the upstream model connection is disrupted. classifyError() had no pattern for this string, so it fell through to UNKNOWN (non-retryable), surfacing the raw error to the user on the first failure instead of running the exponential-backoff recovery. Adds the pattern to the SERVER_ERROR classifier and a friendly message mapping in extractUserFriendlyError(); bumps MAX_RETRIES from 5 to 10 so sustained wiki batch load can ride out provider flaps without surfacing an error to the channel user.
Closes#178
Closes#174
Model identifiers like 'Qwen/Qwen3-Embedding-8B' or
'Pro/deepseek-ai/DeepSeek-V3' carry forward slashes that Spring MVC
decodes from %2F before path matching, so even with the frontend's
encodeURIComponent the request never reaches the handler and 404s out.
The two affected endpoints take modelId as a request param instead:
DELETE /{providerId}/models/{modelId} -> DELETE /{providerId}/models?modelId=...
POST /{providerId}/models/{modelId}/test -> POST /{providerId}/models/test?modelId=...
modelApi.removeProviderModel / testModel in the UI follow suit, passing
the id via axios params so axios handles the URL encoding consistently.
providerId stays as a path variable — provider ids are kebab-case and
never contain slashes.
Closes#175
ModelConfigController.testEmbedding() previously caught and stringified
the exception's getMessage() into the response body without writing
anything to the server log. Operators investigating an Embedding test
failure saw only the truncated client-side message — root causes like
the DashScope-native vs OpenAI-compat routing bug (#166) or the
requireApiKey gap (#167) were invisible server-side.
Add @Slf4j to the controller and log.error the full stack trace
alongside the failing modelId, so future Embedding test regressions are
diagnosable from the server log without redeploying with debug
breakpoints.
Closes#169
ModelConfigService.validateModel() flagged a duplicate when re-adding a
manually-typed (provider, modelName) pair that happened to match a row
with deleted=1 in mate_model_config. The user-visible symptom: adding
'dashscope/qwen3-plus' fails with 'model identifier already exists',
yet the management page shows no such model.
The project itself runs hard-delete via deleteById(), so the user-facing
delete path doesn't create deleted=1 rows. The stale rows come from
schema migrations (V44, V81) that intentionally tombstone bogus catalog
entries — for instance V81 sets deleted=1 on the non-existent
'qwen3-plus' (id=1000000172) so it stays out of routing but preserves
the id for audit. ModelConfigEntity has no @TableLogic, and the project
has no global logic-delete-field config, so LambdaQueryWrapper queries
do not auto-append the deleted filter; the migration tombstones leak
into the validate-model query.
Add an explicit .eq(getDeleted, 0) to the uniqueness check so migration
tombstones don't block legitimate re-adds.
Follow-up: several other queries in ModelConfigService share the same
oversight (list/get methods), and a future migration could drop the
tombstones entirely to align with the V20 hard-delete posture.
Closes#168
The native DashScope provider exposes both chat and embedding models, but
DASHSCOPE_NATIVE_ALLOW_PREFIXES only listed chat families
(qwen-/qwen2-/qwen3-/deepseek-/baichuan/yi-/llama). When a user manually
added text-embedding-v1/v2/v3/v4 to the dashscope provider,
assertModelIdAcceptable() rejected the id because no allow prefix matched.
Add 'text-embedding-' to the allow-list and broaden the doc comment from
"native chat protocol" to "native protocol (chat or embedding)" so the
intent is clear.
Discovery probing is chat-based and will still mark embedding entries
probeOk=false; surfacing them as discoverable embedding suggestions is a
separate follow-up.
Closes#167
EmbeddingModelFactory.buildOpenAi() hard-failed on any provider whose API
key was empty or unusable, so keyless providers like Ollama and OpenCode
(declared with requireApiKey=false) could pass the chat connectivity test
but bounce when the same provider's embedding model was tested.
Mirror the chat path in OpenAiCompatibleChatModelBuilder.buildOpenAiApi:
- If requireApiKey is not explicitly false, an unusable key still throws.
- If requireApiKey == false, the key check is skipped and an empty string
is passed to OpenAiApi.builder() so no Authorization: Bearer header is
attached to the outgoing request.
Closes#166
EmbeddingModelFactory used EmbeddingProtocol.fromProviderId() to pick the
embedding protocol, which substring-matches 'dashscope' / 'qwen' / 'aliyun'
in the providerId. The dashscope-compat provider carries 'dashscope' in its
id but runs in OpenAI compatible mode (chatModel='OpenAIChatModel',
baseUrl='https://dashscope.aliyuncs.com/compatible-mode/v1'). Routing it to
DASHSCOPE_EMBEDDING made DashScopeApi build its native path against the
compat base, producing 404s on every embedding call.
Switch to the chatModel column instead — the same signal ModelProtocol
.fromChatModel() uses for the chat path. chatModel='DashScopeChatModel'
takes the native protocol; everything else (including dashscope-compat)
takes OpenAI-compatible.
EmbeddingProtocol.fromProviderId() is retained for reference but is no
longer called; future callers should follow the chatModel pattern.
Closes#162
require_mention=true previously degraded to a no-op when botPrefix was unset:
shouldProcess() returned true for all messages and checkAccess() fell through
unconditionally, so any group message would be answered — including ones where
the @mention targeted another user.
FeishuChannelAdapter now consults the Feishu SDK's mentions field directly:
- WebSocket: read EventMessage.getMentions(); webhook: read mentions[] from the
JSON payload. In both paths each mention's id.open_id is compared against the
bot's own open_id.
- Bot open_id is fetched lazily via /open-apis/bot/v3/info and cached on the
adapter instance. If the call fails the message is allowed through, matching
the previous behaviour.
- The require_mention gate is applied at the top of handleFeishuMessage so 1:1
chats are unaffected.
Tests: 15 unit cases covering null/empty inputs, bot mentioned, only-other
mentioned, bot among multiple mentions, and malformed payloads.
Register a no-op handler for the bot-added-to-chat event on the Feishu WebSocket EventDispatcher. Without it, adding the bot to a group chat raises HandlerNotFoundException and drops the long connection. Mirrors the existing reaction-event handlers. Fixes#153.
The Feishu SDK EventDispatcher had no handler registered for im.message.reaction.created_v1 / deleted_v1, so adding or removing an emoji reaction raised HandlerNotFoundException and logged an ERROR stack trace. Register no-op handlers to silently ignore these events.
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync
interprets as 'any directory named test at any depth' — so it caught
the root-level /test/ scratch directory (intended) AND every src/test/
under each module (not intended).
Pattern is already anchored to /test (root-only). This commit rsyncs
the accumulated src/test/ tree forward so opensource has the unit tests
that have been written / updated against existing src/main/ code since
the pattern regression. Going forward each per-commit sync will carry
src/test/ files along with the main change.
OAuth token save now promotes the first available chat model when no usable default exists. Default-model resolution and provider availability checks require Provider.enabled=true alongside credentials, so disabled Providers no longer return stale defaults. /models/enabled drops the single-Provider hard-code so OpenAI OAuth and other enabled chat models surface in selectors. Docker exposes the 1455 PKCE callback via MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST (default 127.0.0.1) and a port mapping; deployment mode stays Host-driven.
Add a parallel effectiveAllowedToolsDisplay field on the runtime status payload so the SkillMarket detail drawer can render mcp_<server>_<slug>_<hash> with the raw tool name appended in parentheses, while leaving the original prefixed list unchanged for any caller that needs the machine name. McpSkillBridge#decorateToolNameForDisplay reverses a prefixed name via the per-server cached tool list; the frontend prefers the new display field and falls back to effectiveAllowedTools when the field is absent.
Replace hardcoded zh-CN locale with vue-i18n locale.value, add a yesterday bucket reusing the security.activity.yesterday key, and fall back to YYYY/MM/DD HH:mm for older messages. Computes the previous day with setDate(getDate()-1) so DST transitions stay correct, and short-circuits invalid Date inputs.
Group-chat reply slot fallback:
- The platform blocks proactive sends in group chats; outbound paths
(cron summaries, async-task completions, generated image/music/3D
delivery, TTS audio) silently failed because they fell through to
the proactive-send command. New bounded LRU maps each group chat
to its most recent inbound frame id; the dispatcher prefers that
reply slot and falls through to proactive only for single chats.
- Centralised text and media dispatch through a single helper so the
group rule never has to be re-implemented per outbound path.
Upload size pre-check + auto-downgrade:
- Without client-side limits, oversized uploads streamed for ~1 minute
before the server rejected at the finish step — users saw nothing
arrive in their chat. The new decision layer mirrors the platform's
hard limits and produces three outcomes: rejected with a friendly
reason, downgraded to a generic file delivery with an inline note,
or pass-through unchanged.
- Files over 20MB reject. Images / videos over their 10MB limit
downgrade to file. Voice content that isn't AMR or exceeds 2MB
downgrades. AMR voice within 2MB stays native.
appmsg inbound parsing:
- Forwarded complex messages (document transfers, article links,
miniprogram cards) used to fall into the inbound switch's default
branch and silently drop. The new branch flattens four sub-types
into a text marker the agent reads plus any media that needs to
reach downstream tools — document forwards reuse the same magic-
byte sniff and per-conversation upload layout as native file
inbound, so extension recovery and chat-uploads serving work
identically.
- Article links produce "[链接] title\ndescription\nurl" so the agent
can summarize without round-tripping. Miniprograms surface their
title. Unknown sub-types still emit a generic marker so the agent
is never blind.
WeCom quoted-message context:
- Parse the body.quote field that arrives alongside any inbound message
(text / image / voice / file / mixed sub-types). When a user long-
presses a previous bot bubble and types a follow-up like "解释一下",
the agent now sees both the user's new text and the referenced
content as proper context — replies stay on topic instead of
guessing what was being explained.
- Quoted images / files are downloaded through the same pipeline as
inbound new media (magic-byte sniff, ZIP container peek for
DOCX / XLSX / PPTX recovery, chat-uploads layout) so the vision
sidecar and document tools can actually analyse what was quoted.
- Reading order in the assembled prompt: "[引用消息: ...]\n<user text>"
first, then quoted media parts, then the user's own current-message
media. Mixed quotes flatten into a space-joined summary.
Multimodal sidecar settings preservation:
- The bulk settings PUT used to unconditionally overwrite the vision /
video sidecar model ids — null in a partial payload became "" in
the DB, silently wiping the configured sidecar every time a user
saved an unrelated settings page (System / Music / Image / etc.).
Symptom: "I picked a vision model, saved a different settings tab,
now the bot can't see images anymore."
- Bulk save now guards both keys with non-null checks, matching the
pattern used for music / 3D / image / video / tts / stt blocks.
- A dedicated /settings/sidecar endpoint always writes both keys, so
the sidecar UI can still explicitly clear via null without leaking
the write-on-null semantics into every other settings save.
- Frontend sidecar card switches to the dedicated endpoint; other
settings pages keep their existing partial-payload behaviour.
Inbound (WeCom):
- Save uploaded media under data/chat-uploads/{conversationId}/ with full
fileName/path/fileUrl/storedName/fileSize on the content part. Web mirrors
of an IM conversation now show real thumbnails instead of "未命名".
- Magic-byte sniff (PDF / PNG / JPEG / GIF / Office / ODF / archives /
audio / video) recovers a real extension when the platform omits filename
for forwarded files — no more PDFs labelled "file.bin".
- ZIP container peek distinguishes DOCX / XLSX / PPTX / VSDX / ODT / ODS /
ODP / EPUB / JAR from a plain zip via discriminator paths and the OASIS
mimetype entry.
Outbound (WeCom):
- Chunk upload field name corrected so server-side actually stores the
bytes — file messages used to arrive with correct filename/size but
empty content, breaking every PDF / DOCX / PPTX recipient.
- Scan agent text for served-file URLs in both the text-reply and
content-parts paths; fetch bytes from the in-memory generated-file
cache and dispatch through the native chunk upload + media message
protocol so users receive a tappable file card instead of an
unopenable markdown link. Cache miss surfaces a clear retry hint.
Async tool result forwarding:
- New AsyncTaskMediaDispatcher routes generation completions (image,
video, music, 3D model) to whichever IM channel the conversation is
bound to via ChannelSessionStore + ChannelManager. Web / webchat
conversations are intentionally skipped — their SSE stream already
renders the result.
- Wired into all four generation services so IM users actually receive
generated media as native attachments. Each part now carries an
absolute disk path so adapters read bytes locally instead of round-
tripping through an authenticated served URL.
Slack native file upload:
- SlackChannelAdapter overrides the content-parts dispatch. Image /
audio / video / file / model3d parts ride filesUploadV2 so users see a
file card with preview thumbnail bound to the same thread as the
originating message. Text parts continue through chat.postMessage.
- Resolves bytes from the part's local path, falls back to an HTTP fetch
of fully-qualified URLs.
IM approval hint visibility:
- IM-driven approve / deny / auto-cancel / replay-error hints now go
through saveMessage + tracker broadcast in addition to the channel
adapter, so a Web mirror viewing the same conversationId sees the
resolution. Previously hints reached only the IM channel; the Web
admin console had no record of the outcome.
Adaptive paste-merge debounce:
- WeCom and other IM clients silently split long pasted prompts into
fragments that arrive 0.5-2 seconds apart, missing the existing 500ms
merge window. The agent then saw torn context and emitted multiple
conflicting replies.
- When the merged buffer crosses a content-length threshold, extend the
debounce window so subsequent fragments arrive in time. Default
500ms unchanged for normal short messages.
Resolve ${user.home} and other JVM system properties in MCP server env, headers, and cwd — previously only OS env vars were expanded, causing the filesystem MCP server to fail on Windows where $HOME isn't set.
Temporarily restore McpClientManager.java to its pre-#60 state so the
contributor's PR can squash-merge cleanly with their authorship preserved.
The args-expansion follow-up will land as a separate commit right after.
When an agent had any skill bound, the runtime tool gate was silently
hiding @Tool beans that aren't declared in any skill manifest, even
though the global system prompts (SOUL.md / "Web Search Capability" /
"File Reading Guidelines") explicitly tell the LLM these tools are
available. Result: the model would call search / renderDocx / read_file
/ etc., hit "Tool not found", then either give up or fall back to
unhelpful behaviour (e.g. dumping markdown text instead of producing a
.docx download).
This commit:
- Adds universally-promised, agent-wide tools to SYSTEM_LEVEL_TOOLS so
they bypass the manifest restriction: document/media generation
(renderDocx*, image_generate, music_generate, video_generate),
global capability tools the system prompt mentions (search,
browser_use, read_file / write_file / edit_file /
execute_shell_command, detect_file_type, extract_*_text,
readMateClawDoc), skill discovery siblings (listSkillFiles,
listAvailableSkills), and the delegate triplet (delegateToAgent,
delegateParallel, listAvailableAgents).
- Fixes 5 entries in the prior whitelist whose names did not match
any real @Tool bean and were therefore silently dead:
read_workspace_file -> read_workspace_memory_file
write_workspace_file -> write_workspace_memory_file
list_workspace_files -> list_workspace_memory_files
delegate_agent -> delegateToAgent
datetime -> getCurrentDate / getCurrentDateTime / getCurrentTime
Also adds the missing edit_workspace_memory_file.
- In the chat markdown renderer, strips any hallucinated
https?://<host> prefix from /api/v1/files/generated/<id> download
links before building the <a href>. Multiple LLMs have been
observed prepending bogus hosts when echoing tool-returned download
URLs back to the user, breaking the click. One-line defensive
normalization independent of which model is in use.
Verified end-to-end on a previously-broken agent: search / browser_use
/ execute_shell_command / renderDocx all dispatch correctly now and
the final markdown link is a clean same-origin path. 36 whitelist
entries cross-checked against real @Tool method names.
AgentBindingServiceTest green.
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.
SkillPackageResolver.persistScanOutcome built a fresh SkillEntity with only
id + scan fields, then called updateById. SkillEntity declares six columns
with @TableField(updateStrategy = FieldStrategy.ALWAYS) — name_zh, name_en,
config_json, source_code, skill_content, security_scan_result — so the
ALWAYS strategy emits UPDATE statements that write NULL to every one of
those columns not set on the partial entity.
Effect: every security re-scan that produced a status/findings change
silently wiped skill_content, config_json, source_code, name_zh, name_en
on the row. After importing a custom skill, the first scan tick destroyed
the imported content.
Fix: switch to LambdaUpdateWrapper so the UPDATE only touches the three
scan columns we actually want to change. Other skillMapper.updateById
call sites (SkillService, BuiltinSkillSeedService) pass DB-hydrated
existing entities and are unaffected.
Reported and diagnosed by @pipima9950-glitch in issue #45.
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.
Chat attachments with non-ASCII filenames (e.g. Chinese) get sanitized
at upload time — `人人有虾.docx` is stored as `1777391026594_____.docx`.
Tools then receive only the original filename via '[Attachment] foo.docx'
and fail with 'file not found'.
- renderMessageContent now appends the actual server-side path so any
tool the LLM picks (read_file / extract_document_text /
detect_file_type) gets a path that resolves directly.
- New ChatUploadResolver helper performs basename-suffix matching inside
the conversation's chat-upload directory; ReadFileTool, DocumentExtractTool
and FileTypeDetectorTool fall through to it when the literal path does
not exist (defense in depth for cases where the LLM ignores the path
hint).
Refs https://github.com/matevip/mateclaw/issues/29
Replace the per-startup admin reconciliation in
WorkspaceSchemaMigration.ensureDefaultWorkspaceMembership() with a
one-shot bootstrap. Once the default workspace has any owner, the
method returns immediately, so an operator's deliberate removal of an
admin from the default workspace persists across restarts. If no owner
exists yet, pick the lowest-id active admin and add them as owner; if
no admin exists at all, log a warning and skip rather than failing
startup.
Refs https://github.com/matevip/mateclaw/issues/29
Restart-time backfill in WorkspaceSchemaMigration was inserting every
existing user into the default workspace and copying mate_user.role
('user'/'admin') into mate_workspace_member.role, whose valid domain is
{owner, admin, member, viewer}. Result: non-admin users assigned to
other workspaces were silently re-attached to the default workspace
with role='user', failing roleLevel() lookup and 403'ing on Agents.
- Filter the INSERT on u.role = 'admin' and hard-code the membership
role to 'owner', removing the role-domain mismatch and the
workspace-isolation violation in one change.
- Add V60__fix_invalid_workspace_member_roles.sql (h2 + mysql) to
drop already-corrupted default-workspace rows for users who have a
valid membership elsewhere, and downgrade the orphan rows to
'member' so those users aren't locked out entirely.
Refs https://github.com/matevip/mateclaw/issues/29
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
Volcano Ark exposes a separate 'Coding Plan' subscription endpoint at
/api/coding/v3 with its own coding-tuned model catalog (ark-code-latest,
doubao-seed-code, kimi-k2-thinking, glm-4.7 coding edition, etc.). The
same Volcano API key works against it. Splitting into a sibling
volcengine-plan provider lets users keep chat-tuned and coding-tuned
defaults side by side, and the generalized OpenAI-compatible path
resolver already handles the /v3 suffix without a completionsPath
override.
Adds Flyway V56 (h2 + mysql) and updates the 4 seed-data files with
matching rows (ids 1000000320-325) for fresh installs.
- 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.
Adds explicit user-intent gating to the provider catalog. Fresh installs
get an empty dropdown by default — built-in cloud + local providers
(OpenAI, Anthropic, Ollama, LM Studio, MLX, llama.cpp, etc.) live in a
new 'Add Provider' drawer until the user opts them in. Existing installs
upgrade conservatively: V55 promotes any provider with evidence of use
(real api_key, OAuth token, recent chat usage, or current default model).
Backend
- V55 migration (H2 + MySQL): adds enabled BOOLEAN DEFAULT FALSE on
mate_model_provider, plus 4 promote-to-true UPDATE rules. Also
CREATE INDEX idx_message_runtime_provider_time so the 30-day usage
lookup doesn't full-scan mate_message on heavy users.
- ModelProviderEntity, ProviderInfoDTO: enabled field.
- ModelProviderService:
* listProviders() now filters WHERE enabled = TRUE — chat path,
ModelSelector, Settings/Models main grid see only opted-in rows.
* listCatalog() new — full catalog (enabled + disabled) for the drawer.
* setEnabled(id, enabled) flips the flag, publishes
ModelConfigChangedEvent (re-probe via the existing listener), and
on disable auto-promotes a replacement default model when the
disabled provider owned the current default. Returns EnableResult
so the frontend can fire a toast.
* createCustomProvider sets enabled=true (user just made the row).
- ProviderInitProbe.listConfiguredProviders also filters enabled=true —
no point probing rows the user can't see.
- ModelConfigController: GET /catalog, POST /{id}/enable, POST /{id}/disable.
- Plugin-registered ChatModels are unaffected — they live in
pluginChatModels (in-memory map), don't go through DB listProviders,
so the enabled filter doesn't strand them.
Frontend
- New types: ProviderInfo.enabled, EnableResult.
- New API: catalog / enableProvider / disableProvider.
- New composable useProviderEnablement: catalog ref, drawerOpen,
togglingId, loadCatalog, openDrawer / closeDrawer, enableProvider,
disableProvider (fires defaultSwitchedToast on auto-switch).
- AddProviderDrawer.vue: lazy-loaded, reuses DoctorDrawer's Teleport +
overlay + slide-in panel pattern. Two groups (cloud / local),
unenabled rows surface to the top of each group, enabled rows show
an 'Enabled' badge instead of a button. Mobile: full-screen sheet
that slides up from below.
- ProviderCard: new 'Disable' button with soft-danger styling on
enabled providers — soft-hide that keeps the config; user can
re-enable from the drawer.
- Settings/Models index.vue:
* Two top CTAs: 'Enable Provider' (drawer) and 'Custom' (existing
custom-create modal) — distinct workflows, both surfaced.
* Empty state with prominent 'Enable Provider' CTA when zero
enabled providers — paired with onMounted auto-open of the
drawer (sessionStorage guard so closing it doesn't bring it
back on the next route visit in the same session).
* Deep-link: ?addProvider=1 query forces the drawer open and
strips itself after, so a back/forward doesn't re-fire the open.
- ModelSelector: when groups.length === 0 and not searching, show
'No providers configured -> Configure' CTA linking to
/settings/models?addProvider=1 — the natural flow when a fresh
user opens chat before configuring anything.
- i18n: 13 new keys per locale (zh-CN + en-US) plus common.close.
Migration safety
- Conservative default policy: only rows with concrete evidence of
use are auto-enabled; everything else stays hidden. Upgrade users
may notice unused built-ins disappearing from their dropdown —
that's the intended cleanup.
- mate_message index added so the 30-day usage rule doesn't full-scan
on large installations; FlywayRepairConfig handles redeploy idempotency.
Tests
- ModelProviderServiceEnableTest covers all 7 enable/disable branches:
flag flip + event publish, no-op on already-{enabled,disabled},
default-switch when disabled provider owned current default,
no-switch when default belongs elsewhere, no-replacement returns
unchanged, getDefaultModel exception path, candidates with no
models are skipped.
- ProviderInitProbeTest: helper provider() now sets enabled=true so
the new probe filter doesn't strand existing fixtures.
- vip.mate.llm.** suite: 125 tests green. vue-tsc 0 errors. Browser
page renders with both new buttons + drawer.
Reshapes the Settings/Models frontend to match the channel-module split
convention (commit 22894ac4 'perf(channels): split Channels.vue...'),
zero behavior change. Paves the way for a follow-up that adds an enabled
column + AddProviderDrawer without bloating useProviders back to monolith.
Frontend split
- useProviders.ts goes from 615-line monolith to a 48-line facade that
composes five single-responsibility slices:
* useProviderList — providers / activeModels / currentProvider,
loaders, status pill, icons
* useProviderForm — create/edit modal + form, save/delete
* useProviderDiscovery — manage-models modal, discovery, connection
and per-model tests
* useProviderOAuth — openai-chatgpt + claude-code OAuth flows
* useProviderPool — manual reprobe (most pool surface inlined to
ProviderInfo.liveness in the prior liveness change)
Cross-composable refs flow via dep-injection arguments — no module-
level state, no circular deps. Each composable stays independently
testable.
- Pure helpers extracted to src/utils:
* safeJson.ts — strict JSON-object parser
* modelProtocol.ts — protocol <-> ChatModel class translation
- Modals (ProviderConfigModal, ManageModelsModal) loaded via
defineAsyncComponent so the route's first paint doesn't drag along
~30KB of form/auth UI.
- el-skeleton placeholder during the initial Promise.all so the page
paints something instead of blank-then-pop.
Layout regression fix
- MainLayout's <keep-alive> slot used :key='workspaceRouteKey' (a
workspace-scoped string), shared between two <component v-if> blocks.
Adding a second keepAlive route would have caused two components to
mount side by side, because Vue saw identical keys and patched in
place across the v-if boundary. Switched the key to
${workspaceRouteKey}:${route.path} so different routes get distinct
vnode identities while workspace switching still busts the cache.
Discovered while implementing the split — the multi-line HTML
comment also had to live OUTSIDE <keep-alive>, since KeepAlive
treats comments as children and rejects 'more than one'.
Embedding section title fix (drive-by)
- EmbeddingModelsSection.vue's scoped style didn't redeclare
.group-title's flex layout, so the icon stacked above the title
text instead of sitting inline. Added the missing flex rules
locally — now matches the local-models / cloud-models group headers.
Verification
- vue-tsc 0 errors.
- Browser end-to-end: 27 cards render correctly, modals open via lazy
load, /channels <-> /settings/models switch four times in a row with
exactly one page title visible at each step (no stacking).
Three knots untangled so an image sent from DingTalk lands in both the
LLM's multimodal prompt and the chat history bubble:
- Prefer MessageContent.downloadCode (universal, used by the new
api.dingtalk.com messageFiles/download) over pictureDownloadCode
(legacy oapi field). Sending the legacy code to the new API got
HTTP 500 unknownError, which was the original 'image not recognized'.
- After fetching bytes, persist to ~/.mateclaw/media/dingtalk/ so vision
can read via FileSystemResource, AND stuff the same bytes into
GeneratedFileCache so the UI gets an /api/v1/files/generated/{id} URL
to render. Without the URL the message bubble showed an empty card.
- Carry filename / contentType / size on the MessageContentPart so the
chat history doesn't fall back to the 'unknown' caption.
Same treatment applied to the richText branch (inline images from the
PC client) and threaded through the Stream SDK path.
Bundles in the prerequisite ChannelManager wiring of GeneratedFileCache
into DingTalkChannelAdapter and the new DingTalkMediaUploader used by
the outbound attachment flow that this work depends on.
Known limit: GeneratedFileCache TTL is 10 min — fresh refreshes work,
but viewing the image after a JVM restart needs a stable on-disk
serving endpoint, which is intentionally out of scope here.
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.
Reported issue: click 'scan to create' -> button momentarily flickers
loading -> button re-enables but no QR shows up -> blank for 1-2 seconds
-> QR suddenly appears. Looks broken even though it works.
Root cause: loading.value flipped back to false the moment the begin HTTP
call returned (sessionId in hand), but the actual QR image only arrives on
the first status poll, which the existing code waited a full 2 seconds
for. Between begin completing and the first poll firing the UI was a
disabled button + nothing.
Three coordinated changes:
- useFeishuAppRegister and useDingTalkAppRegister: keep loading.value true
through begin AND across the polls, only flip false when the QR image
is actually populated (or a terminal failure status arrives). Also run
an immediate first poll right after begin instead of waiting for the
setInterval tick — usually the first poll already has the rendered QR
for dingtalk, and pushes the feishu user roughly 2 seconds closer.
- ChannelEditModal: same-sized loading placeholder (min-height 240px,
matching the QR card) that renders when loading is true and no QR is
in hand. CSS spinner ring tinted with the channel brand color (feishu
indigo, dingtalk blue) and a new
channels.{feishu,dingtalk}Register.qrcodeLoading hint. The placeholder
swaps to the real image with no layout shift.
- i18n: new qrcodeLoading key in zh-CN and en-US for both flows.
Net effect: click to spinner-visible is ~50ms; the user is never staring
at a frozen button-without-content again.
The stream SDK delivers voice messages as ChatbotMessage with msgtype=audio
and the server-side ASR result already filled into MessageContent.recognition
(same shape as WeCom's voice.content). The adapter's handleStreamMessage
only read msg.getText(), which is null for audio events, so the message
landed in handleWebhook with no msgtype, fell through to the default text
branch, found null content, and got dropped at 'Empty message content,
ignoring'. From the user's side: send a voice, nothing happens, no log of
the attempt.
Two surgical edits:
- handleStreamMessage now checks getContent().getRecognition() first; if
present and non-blank, builds payload {msgtype: audio, audio: {recognition}}
before falling back to the existing text path. The earlier comment about
richText being handled inside handleWebhook was wrong — picture and
richText also need their fields propagated through the payload Map; left
a TODO for them.
- handleWebhook gains an explicit case 'audio' branch that pulls text out
of audio.recognition and pushes it onto contentParts.
- ChannelMessage.inputMode now reflects 'voice' when msgtype=audio,
mirroring feishu's behavior so downstream code (memory-extraction
filters, voice-themed system prompts) can tell text vs voice turns apart.
No STT call required — DingTalk transcribes server-side and ships text in
the webhook, so this is a 0-network, 0-config fix.
Mirrors the feishu one-click flow: scan a QR with the DingTalk app,
approve, and the bot's client_id / client_secret get auto-filled instead
of forcing the user through the open-dev console. Saves about seven
manual steps per channel setup.
Backend
- Bump dingtalk-stream from 1.3.5 to 1.3.12. Diff against the classes we
depend on (OpenDingTalkStreamClient, ChatbotMessage, MessageContent,
GenericEventListener) is empty — pure point-release bumps, no API churn.
- New DingTalkAppRegistrationService: synchronously runs init + begin
against /app/registration/{init,begin} on oapi.dingtalk.com to obtain
the device_code and verification URL, then spawns a daemon worker that
polls /app/registration/poll every 5s until SUCCESS / FAIL / EXPIRED is
returned. Sessions evict after 7 minutes, worker has a 6-minute hard
runtime cap, transient HTTP errors do not terminate the loop. Same
shape as the feishu service, but written from scratch because the
dingtalk-stream SDK doesn't wrap this OAuth device flow.
- Two new endpoints under /api/v1/channels/webhook:
POST /dingtalk/register/begin returns session_id;
GET /dingtalk/register/status returns status + qrcode_img (data URI
PNG, ZXing-encoded from the verification URL, matching the feishu and
weixin flows). Status surface: waiting / confirmed / expired / denied.
Frontend
- channelApi.dingtalkRegisterBegin / dingtalkRegisterStatus.
- New useDingTalkAppRegister composable, structurally identical to
useFeishuAppRegister minus the domain argument. Stops polling on
terminal status, fires onConfirmed with {clientId, clientSecret}.
- ChannelEditModal: dingtalk-register-card rendered when channelType is
dingtalk, scoped DingTalk blue (#1f79ff) to differentiate from feishu's
indigo. onConfirmed writes channelConfig.client_id / client_secret so
the existing form fields update reactively.
- i18n: channels.dingtalkRegister.* keys for title / hint / button states
/ scan / confirmed / expired / denied / startFailed.
Saves the user the entire 'go to the open platform -> create an enterprise
app -> copy App ID and Secret' detour. Click a button in the channel form,
scan the QR code, confirm authorization, credentials are auto-filled.
Backend
- Bump com.larksuite.oapi:oapi-sdk from 2.5.3 to 2.6.1, which adds the
scene/registration package wrapping the device-code flow.
- New FeishuAppRegistrationService: each begin() creates a sessionId,
spawns a worker thread, runs the SDK's blocking RegisterApp.register
with onQRCode and onStatusChange wired into a per-session state machine
(PENDING -> WAITING -> CONFIRMED / EXPIRED / DENIED / ERROR). The
session caches the QR data URI so ZXing only encodes once per attempt.
Sessions evict after 5 minutes so closed browsers don't leak the map.
- Two new webhook endpoints under /api/v1/channels/webhook/feishu:
POST /register/begin returns session_id, GET /register/status returns
status + qrcode_img (data URI base64 PNG, ZXing-encoded from the SDK's
verification URL — the raw URL would render as a broken image, so the
encoding step matches the WeCom flow).
- SDK detail caught the hard way: don't pass .domain() or .larkDomain().
The SDK defaults are accounts.feishu.cn / accounts.larksuite.com (the
registration endpoints). open.feishu.cn is the open-API endpoint, a
completely different service. Passing the wrong one makes the SDK parse
HTML as JSON and emit invalid_response.
Frontend
- channelApi: feishuRegisterBegin / feishuRegisterStatus.
- New useFeishuAppRegister composable: state machine that begins the
session, polls status every 2s, prefers qrcode_img over qrcode_url for
the <img> src, stops on terminal status, fires onConfirmed with
{appId, appSecret}.
- ChannelEditModal: a new feishu-register-card above the wecom one. The
composable's onConfirmed writes channelConfig.app_id / app_secret, so
the existing form fields update reactively.
- i18n: channels.feishuRegister.* keys for title / hint / button states /
scan / confirmed / expired / denied / error.
The zh seed planted channels with English display names (DingTalk Bot,
Feishu Bot, WeCom Bot, ...). The type label localized correctly but the
per-channel name stayed English on the cards page even when UI was Chinese.
- Update zh seed files (data-zh.sql + data-mysql-zh.sql) so fresh installs
get Chinese names from the start: Web 控制台, 钉钉机器人, 飞书机器人,
Telegram 机器人, Discord 机器人, 企业微信机器人, QQ 机器人, Slack 机器人.
id=1000000008 (微信) was already Chinese; left alone. en seeds untouched.
- Add V54 migration that flips existing zh-CN installs in place. Each
UPDATE is gated on system_setting language=zh-CN AND the channel name
still equal to its original English seeded value, so user-renamed
channels are left alone. Subsequent runs match no rows (idempotent).
h2 and mysql variants stay in lockstep.
Backend (FeishuChannelAdapter):
- Default connection_mode flips webhook -> websocket on doStart and doReconnect.
- Stale event filter: drop events whose message.create_time is older than
stale_event_threshold_seconds (default 30s) so SDK reconnect replays do not
re-trigger the agent.
- Silent disconnect watchdog runs every 60s; if no events arrive for
silent_disconnect_threshold_seconds (default 1800s) after the first event,
call onDisconnected to force a reconnect cycle. Setting the threshold to 0
disables the watchdog. The watchdog is scheduled before wsClient.start() on
the bring-up path because that call blocks indefinitely.
- Quoted message context: when a reply has parent_id set, fetch the parent
via GET /open-apis/im/v1/messages/{id}, summarize per msg_type (text / post
first paragraph / [Image]/[File]/[Audio]/[Video] placeholders, capped at
200 chars), and prepend [Quoted: ...] to both content text and the first
content part. LRU-cached (200) per message_id.
- AbstractChannelAdapter gains getConfigLong helper for numeric config keys.
Frontend:
- types/index.ts feishu fields: default connection_mode is websocket; the
recommended option moves to the top; verification_token and encrypt_key
get showIf so they only render in webhook mode; new enable_quoted_context
switch (default on) exposes the quoted-message feature.
- ChannelEditModal builds a feishu-specific WEBHOOK_GUIDES path that picks
webhookStep vs websocketStep based on connection_mode, so users only see
steps for the mode they're using.
- i18n: split feishu.step3/step4 into webhookStep/websocketStep, rename
step5 to permissionStep. Channel type labels in zh-CN drop bilingual
prefix (e.g. 'Feishu / Lark (飞书)' -> '飞书').
Migrations:
- V52 was a no-op the first time it ran (matched compact JSON only) and
Flyway refused to re-run after the SQL was fixed. V52 is documented as a
no-op; V53 carries the actual UPDATE with REPLACE covering both compact
and pretty-printed JSON, and an idempotent WHERE for rows already on
websocket. h2 and mysql variants stay in lockstep.
- Extract create/edit modal into ChannelEditModal.vue (defineAsyncComponent),
shrinking Channels.vue from 1438 to 370 lines and dropping ~30KB from the
initial route chunk.
- Move side-effect logic into composables: useWeixinQrcodePoll (QR + 2s status
poll, auto-cleanup) and useWecomBotAuth (lazy SDK script with module-level
promise dedupe). Pure config-JSON helpers move to utils/channelConfigJson.ts.
- Switch i18n locales from static imports to dynamic import keyed by current
locale; applyLocale becomes async to avoid first-render flicker.
- /channels route opts into keep-alive (meta.keepAlive=true). Channels.vue
pauses status polling in onDeactivated and resumes in onActivated, with an
isActive guard to prevent late-resolving timers from leaking after navigation.
- Initial load goes from serial 3-RTT to Promise.all + 4-card el-skeleton.
When SSE setup fails (e.g. workspace permission denied for shared channel
conversations opened from the web console), the failed turn is never
persisted on the backend. Two issues made the failure invisible to the user:
- The fallback errorInfo dropped data.message, so the inline retry card fell
back to the generic "请求过程中遇到了意外问题" template instead of the
actual reason. Carry rawMessage through, and lower the MessageBubble
display threshold from >8 to >3 chars so short-but-informative messages
(7-char Chinese / "Forbidden") aren't filtered out.
- The status-poll loop in useChat overwrote the local-only failed turn
with the server's "no message" view, erasing the inline retry card.
Skip the merge for turns that exist only locally and are in error state,
so the user can still see the failure and retry.
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.
- Reconcile approval status atomically: DB row, message metadata, in-memory store
- Approve and deny both flip the tool-call card + timeline segment to a terminal
state on the gate message — no more orange spinner stuck after a decision
- Frontend hydrate matches by pendingId and reverse-converges to expired so a
refresh after server-side timeout / consume clears the banner without restart
- Stop sweep, GC timeout, and JVM restart all close the loop with consistent
state
- Remove the dead REST /approve endpoint + matching frontend client export so
there is only one resolve path to maintain
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.
Three bugs surfaced when a non-admin workspace member opened the channel
admin page:
- vue-i18n "Invalid linked format" when '@' appeared in message strings
without the linked-format escape. Replaced literal '@' with vue-i18n v9
literal interpolation {'@'} in both zh-CN.ts and en-US.ts (6 strings:
QQ guide step3, accessControl requireMention/Tooltip).
- 403 from WorkspaceAccessInterceptor was being treated as 401 by the
axios interceptor and the chat SSE handler, clearing the token and
redirecting to /login. Split the two:
* 401 = authentication failure -> handleAuthFailure (logout)
* 403 = authorization failure -> keep session, surface to caller
Now a member who lacks workspace permission sees a toast instead of
being silently logged out.
- Two backend exception sites threw with the default code=500 for what
is semantically an auth/authz event, contradicting the codes returned
elsewhere for the same business event:
* AuthService.login() bad credentials 500 -> 401
* WorkspaceService.requirePermission() 500 -> 403
This aligns service-layer denials with SecurityConfig (401 for missing
JWT) and WorkspaceAccessInterceptor (403 for permission denied), so
the same business event always produces the same code.
Foundation for the ghost-approval root-cause fix.
Adds ResolveOutcome / MetadataDecision; rewrites ApprovalWorkflowService so
every resolve / consume / timeout / supersede transitions through one
two-phase contract: snapshot → DB UPDATE conditional on status=PENDING →
metadata reconciliation → afterCommit memory mutation. ChatController,
ChannelMessageRouter, and ApprovalController all switch to the workflow;
ApprovalService.resolve / resolveAndConsume / consumeApproved /
cancelStalePending / denyAllByConversation are physically removed so
DB-bypass is no longer reachable at compile time.
Specific fixes:
- recoverFromDb preserves DB pendingId + createdAt (was generating fresh
random ids, breaking every later DB sync)
- effectiveExpireAt = expireAt ?? createdAt + PENDING_TTL: legacy rows
with NULL expireAt no longer resurrect as live PENDING after restart
- markPendingApprovalsResolved flips pendingApproval.status + currentPhase
+ MessageEntity.status atomically (was only flipping the first field;
message.status uses existing completed/stopped, not approved/denied,
to stay within the frontend Message.status union)
- GC scheduler moves to ApprovalWorkflowService; timeouts and overflow
evictions now sync DB + metadata + memory through markTimeout
- DB UPDATE rows=0 returns alreadyResolved (concurrent-resolve safe);
exception propagates so @Transactional rolls back; memory stays untouched
- expireRecoveredRow gates metadata write on DB success (was writing
metadata even when DB update failed, producing the worst-case ghost)
- Mockito JDK 21 agent attach fixed via maven-dependency-plugin properties
+ surefire argLine (no more flaky self-attach across machines)
Tests: 34 new across 4 classes (recovery, resolve, GC, metadata sync).
Full suite: 788 / 788.
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.
Two follow-up improvements on top of renderDocxFromFile so the docx
pipeline can handle real long-form deliverables instead of just
prose-only memos.
Image embedding (P1).
MarkdownDocxRenderer now recognizes single-line  markdown
and embeds the referenced file via POI's XWPFRun.addPicture():
- PNG / JPG / GIF / BMP read straight from disk
- SVG rasterized via Apache Batik (PNGTranscoder, target width 1400px)
before embedding — OOXML stores raster images, so any vector source
needs conversion. Batik runs in-JVM, no rsvg-convert / cairo on host.
- Pictures are pinned to roughly the printable page width (≈ 5.77 in
for A4 minus default 1800-twip margins) and given a 4:3 height
fallback. Mixing images inline with other paragraph text is not
supported by design — the markdown subset assumes one image per
block paragraph. Inline images would require splitting paragraphs
across runs with explicit positioning, well beyond what this
renderer covers.
- Failure modes (missing file, unsupported format, Batik blowing up)
emit an italicised "[image: alt — reason]" placeholder so the rest
of the document still renders; the agent can read its own log to
see why the picture didn't make it.
- Adds two transitive deps via pom: batik-transcoder + batik-codec at
1.18, ~10 MB combined. Worth it given the alternative is shelling
out to system tooling.
Multi-file render (P2-lite).
New tool renderDocxFromFiles(List<String> filePaths, filename, pageSize)
reads several markdown files in order and renders one combined docx.
Lets the agent split a 30-page proposal into cover.md / ch1.md /
ch2.md / appendix.md and produce a single deliverable in one tool
call. Each path goes through WorkspacePathGuard.validatePath; any
empty or unreadable file aborts with a typed error so the agent
fixes its file list before retrying. Files are joined with a blank
line — no separator markup is injected, headings carry over cleanly.
I deliberately did NOT build the heavier mutable-docx state
("appendDocxChapter / finalizeDocx") flavor of P2: the multi-file
form covers the same workflow with no per-conversation state to
clean up, and the agent can iterate by rewriting the chapter file
and re-running the tool. Stateful append can come later if a
streaming use case actually shows up.
renderDocx and renderDocxFromFile @Tool descriptions updated to point
the agent at renderDocxFromFile for >5 KB markdown and to advertise
the new image-embedding capability.
renderDocx requires the markdown body to flow through the LLM as a
tool argument. For an 80 KB project proposal that's ≈ 20 K tokens of
streaming output spent just to repeat back content the model already
wrote to disk a turn earlier — multi-minute generation, real money.
renderDocxFromFile takes a file path instead. The agent uses
write_file / edit_file to assemble the markdown locally, then calls
this tool with just the path. JVM reads the file in one IO syscall
and feeds it to the existing MarkdownDocxRenderer. Token cost drops
from ≈ 20 K to ≈ 50 (the path string).
Behavior:
- Path resolution honors WorkspacePathGuard, same boundary as
read_file / write_file. No path traversal.
- UTF-8 read; rejects empty / missing / non-regular paths with
typed error messages so the agent can recover.
- Output cached in GeneratedFileCache and returned as a relative
/api/v1/files/generated/{id} link, with the same anti-host-
hallucination instruction renderDocx already carries.
- Same supported markdown subset (headings, bold, lists, tables).
Image references () still render as raw text — full
image embedding (P1) and SVG → PNG conversion (also P1) need
Apache Batik plus image-rendering plumbing in MarkdownDocxRenderer
and is tracked separately. Chapter-mode merge (P2) likewise needs
its own plumbing.
The @Tool description tells the agent to prefer this path when
markdown exceeds ~5 KB and shows the full write_file →
renderDocxFromFile workflow inline.
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.
Same bug as the prior queue-drop fix in doOnComplete, but in the
sister branch that fires when the agent's reactive stream errors
out (CancellationException from a user stop). The guard
cr.queuedInput() != null && !(isUserStop && !isInterruptFollowup)
mis-classified "user stopped, no interrupt-with-followup, but a
message is in the queue" as an explicit abort and silently dropped
the freshly-typed follow-up.
The frontend's enqueue path never sets interruptType — it just
calls requestStop + offers to messageQueue. Whoever puts a message
in the queue means it; just run it. Aligns with doOnComplete and
the four other queue-launch sites in this controller.
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'.
- Drag-over highlight (orange border + shadow + arrow icon) on the upload
zone, using dragCounter to prevent flicker over nested children
- Optimistic list items appear immediately on drop/select with UPLOADING
badge and progress bar, before the HTTP request completes
- Wire axios onUploadProgress through api.uploadRaw → store.uploadRawFile
so the progress bar tracks real byte transfer (0–100%), with an
indeterminate shimmer until the first tick
- try/catch around every upload: on failure, placeholder flips to an error
state with ElMessage.error toast and a × dismiss button
- Upload all dropped/selected files concurrently via Promise.all
- i18n keys added (zh-CN + en-US): dropToUpload, uploading, uploadFailed,
status.uploading, progress.uploading
- docker-compose.yml: pass SEARXNG_BASE_URL into mateclaw-server so the
app can reach the searxng sidecar container out of the box (default
http://searxng:8080).
- SystemSettingService: resolveSearxngBaseUrl() now falls back to the
SEARXNG_BASE_URL env var when no DB value is set, so Docker users no
longer need to configure it manually in the UI.
- V38 migration (h2 + mysql): expand mate_wiki_chunk.content from TEXT
(64KB) to MEDIUMTEXT (16MB) so large Chinese chunks (~30k chars
≈ 90KB UTF-8) no longer overflow.
- Add Node 22/pnpm stage to mateclaw-server/Dockerfile so docker compose
up -d --build produces a fully working image (the Vue SPA is now built
inside the image and copied into the JAR's classpath/static).
- Remove DASHSCOPE_API_KEY from .env.example and root docker-compose.yml;
LLM API keys are configured post-startup via the model management UI.
- mateclaw-server/settings.xml: Maven mirror routes requests through Aliyun
so dependency:go-offline no longer hangs in restricted networks
- Dockerfile: COPY settings.xml into /root/.m2/ before any mvn command
- docker-compose.yml: fix port mapping 18080->18088 (app listens on 18088)
- docker-compose.yml: remove legacy schema.sql/data.sql MySQL mounts;
Flyway manages schema creation from V1 baseline on startup
Contributions land in the upstream private tracker, not here.
Removing .github avoids confusing contributors into opening PRs
against a mirror that never merges directly.
Five-commit bundle brings the Dream v2 P1 engine layer online, sitting
on top of the lifecycle mediator foundation already merged.
B.1-B.4 · Schema + records
- Flyway V26 (dream_report) + V27 (memory_recall review fields),
both h2 and mysql
- DreamReportEntity + DreamMode + DreamStatus enum + record types
- DreamReportMapper repository layer
B.5-B.8 · Consolidate refactor + focused dream
- MemoryEmergenceService refactored for plug-in dream modes
- MemoryRecallService extended with promoted/rejected review fields
- Focused dream endpoint + prompt template
- MemoryController exposes the review/trigger surface
B.9-B.10 · Monthly archive service
- MemoryArchiveService rolls cold promoted entries into archival rows
and reclaims daily_count storage
- DreamingScheduler runs archive job on its own schedule
B.12-B.14 · Tests
- MemoryArchiveServiceTest
- DreamFlagGuardTest
- DreamV2AcceptanceIT (end-to-end acceptance under feature flag)
Plus a verification script + HTTP e2e kit in the private test/ dir,
used for local staged rollout — not part of the open-source
distribution.
All features stay gated behind the mate.memory.dream.* flags from
Phase 1. Enable per-phase after staging validation.
beforeLlmCall / afterLlmCall / onSessionEnd only logged on failure,
making flag on/off indistinguishable in logs. Add debug lines on the
success path so lifecycle activation is observable.
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.
Five surgical edits (en + zh parallel):
1. Tagline: 'Your AI needs a Plan B.' — category-defining one-liner
replacing the prior descriptive 'fourteen brains' version.
2. New 'AI is becoming infrastructure' section anchors MateClaw to
the 2026 industry inflection point (the March Claude outages,
57% of enterprises running agents in production).
3. Honest peer comparison — OpenClaw and Hermes Agent added to the
table with accurate facts (both are multi-provider, both are
personal-first tooling). Windsurf removed. License fixed:
OpenClaw is MIT.
4. Reframed competitive positioning under the table — dropped the
self-flattering caption and replaced with an honest split:
OpenClaw and Hermes are for single-user laptops; MateClaw is
the team-grade version with RBAC, approval, audit, admin
dashboard, and a Spring Boot core.
5. Project structure corrected to list only modules that actually
ship to the open-source repo (server, ui, webchat, plugin-api,
plugin-sample). Desktop noted separately as a binary release.
Also: UI-path hint in the failover section, a new centered $0 cost
statement after the surfaces table, Java badge bumped to 21+.
Hero + headline
- Lead with the insight most AI tools fail on: vendors go down, memory
resets, one channel = one product. Position MateClaw as the whole
widget — one deployment covering reasoning, knowledge, memory, tools
and channels.
Three product anchors replace the old feature wall
1. Multi-model failover — primary vendor key fails, runtime routes to
the next healthy provider (DashScope / OpenAI / Anthropic / Gemini
/ DeepSeek / Kimi / Ollama / LM Studio / MLX, 14+ total) with a
provider health tracker cooling down bad vendors.
2. Knowledge that links itself — LLM Wiki digests raw material into
linked pages with citation-level traceability.
3. Five surfaces, one brain — Web Console, Desktop (bundled JRE 21),
Webchat Widget, 7 IM channels, Plugin SDK.
Comparison table tightened
- 13-column × 7-product matrix collapsed to 5 rows × 4 competitors,
focused on dimensions where MateClaw carves real space.
Project structure corrected
- Previous version only listed 3 modules. Now lists all seven:
mateclaw-server / -ui / -desktop / -webchat / -plugin-api /
-plugin-sample / matevip-sites.
Tech stack updated
- Java badge bumped to 21+ (was 17+); Flyway surfaced; Webchat row
added.
Size: each README 230 → 203 lines.
Three bugs fixed:
1. Badge stays "processing" after job completes: pollJobs() never called
fetchRawMaterials() when a job reached terminal status, so
raw.processingStatus stayed processing in the store. Fix: detect
terminal job status in pollJobs, trigger fetchRawMaterials to sync.
2. "Completed" dot pulses instead of solid: dotClass() treated completed
the same as in-progress (target === cur → active). Fix: add
isTerminal computed (includes completed), return done for all
dots at or before the terminal position — no pulse animation.
3. Stage label stays orange at terminal: same cause — active class
applied regardless of terminal state. Fix: use done class for
terminal labels (green instead of orange).
Also: v-if on JobStageBar now shows for terminal status jobs (not just
stage !== queued), so completed/failed stage bars remain visible.
Root cause: processRawMaterial() created a job record at queued stage
but never called jobService.transition() during processing. The job row
stayed at queued forever, so the stage bar never advanced.
Backend (WikiProcessingService):
- Transition job to ROUTING immediately after creation
- Transition to PHASE_A_RUNNING before chunk processing begins
- Transition to COMPLETED/PARTIAL/FAILED at the end based on finalStatus
- Transition to FAILED in the catch block on unhandled exceptions
Backend (WikiProcessingJobService.transition):
- Handle FAILED, PARTIAL terminal stages (set finishedAt + status)
- Handle non-terminal intermediate stages (set status to running)
Frontend (JobStageBar.vue):
- Add stageMapping for backend stages not shown as dots: phase_a_done →
phase_b_running, failed/partial/cancelled → completed position
- Guard stageIndex() against -1 (unknown stages default to all-pending)
- Terminal failure states show red failed dot instead of pulsing active
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).
Two real bugs the user restart surfaced — both turned healthy providers
into HARD-removed false positives.
Bug #1 — URL duplication
OpenAiCompatibleListModelsProbe always concatenated /v1/models, so
providers whose Base URL already includes the version segment got the
wrong URL:
LMStudio http://localhost:1234/v1 → /v1/v1/models → 404
ZhipuAI .../api/paas/v4 → /v4/v1/models → 404
Fix: detect a trailing /vN suffix and append /models instead. Six unit
tests in OpenAiCompatibleListModelsProbeTest lock the rule down.
Bug #2 — 404 false positives
Kimi for Coding API does not expose /v1/models even though chat works
fine, so the probe correctly received a 404 and incorrectly HARD-removed
the provider from the pool. Other vendors will hit the same — listing
is not a universal contract.
Fix: classify HTTP responses semantically.
401 / 403 → HARD remove (real auth failure)
404 / 405 / 410 → fail-open (endpoint missing, server may be alive)
other 4xx / 5xx → fail-open (probe inconclusive — let chat decide)
network errors → fail (unreachable)
This is the same philosophy as ChatGPTOAuthStatusProbe: when we cannot
cheaply confirm health, we do not proactively penalize the provider.
Same logic applied to Anthropic + DashScope probes for consistency.
Net effect on the user deployment after restart:
- kimi-code stays in pool (404 → fail-open) → primary path works again
- lmstudio + zhipu-cn also stay in pool (URL bug fixed)
- dashscope + ollama unchanged (real 200 OK)
Tests: 6 new for resolveModelsPath. The 2 unrelated WikiRawMaterialDedupTest
failures pre-date this commit and live in ba86bea.
Root cause: addFile()/addText() hash dedup only matched rows with
status=completed, so the same file uploaded while in partial/pending/
processing/failed status would create a duplicate row.
Fix:
- Remove .eq(processingStatus, "completed") from dedup queries — match
any non-deleted row with the same content hash in the KB
- On dedup hit: completed/pending/processing → return as-is;
partial/failed → trigger reprocess (partial enters resume branch)
- Clean up the newly uploaded temp file when dedup discards it
- Frontend: uploadRawFile/addRawText check for existing id in the list
before unshift to prevent visual duplicates
Test: WikiRawMaterialDedupTest — 10 cases covering all 5 statuses,
reprocess triggers for partial/failed, no-op for others, insert only
when no match.
Three root causes for broken progress:
1. JobStageBar was shown whenever a job record existed (even at queued
stage), hiding the working SSE-driven progress bar. Fix: only show
JobStageBar when job.stage !== queued.
2. SSE connection only opened when hasProcessing was true (status ===
processing), but reprocess sets status to pending first. Fix:
include pending in the hasProcessing check.
3. After reprocess, if processing finished before SSE connected, the
status badge stayed on pending forever. Fix: immediately set local
status to processing after reprocess API call, clear stale job
entries, and add delayed re-fetches (5s/15s) as safety net.
Also: clear rawJobs entries on raw.completed/raw.failed SSE events to
prevent stale JobStageBar from lingering after processing ends.
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.
Track the primary model health, not just fallback entries
- NodeStreamingChatHelper accepts primaryProviderId via a new 5-arg
constructor; AgentGraphBuilder passes ModelConfigEntity.getProvider()
- Before the 5-retry primary loop, check
healthTracker.isInCooldown(primaryProviderId): if true, log + broadcast
"主模型暂时不可用(冷却中),直接尝试备选模型..." and short-circuit
straight to the fallback chain. Prevents a degraded primary from
burning 30+ seconds of backoff on every conversation turn.
- recordPrimary(success/failure) now fires on every primary verdict —
AUTH, BILLING, MODEL_NOT_FOUND, EMPTY_RESPONSE, generic UNKNOWN, and
the explicit success path. Three consecutive failures push the
primary provider into cooldown automatically.
- Legacy 1/2/3-arg constructors leave primaryProviderId null; tracking
silently disables for them so existing tests/wiring keep working.
Split BILLING and MODEL_NOT_FOUND out of CLIENT_ERROR / AUTH_ERROR
- BILLING (HTTP 402, "insufficient_quota", "credit balance is too low",
"billing_hard_limit_reached", "quota exceeded"): payment failure on
primary does not kill the call — a different provider may have credits.
Skips same-model retries and heads to fallback chain.
- MODEL_NOT_FOUND (HTTP 404, "Model not exist", "model_not_found",
DashScope "[InvalidParameter] url error"): unknown model id will not
start working on retry. Was previously misclassified as CLIENT_ERROR
and terminated the whole call; now routes to fallback so a different
provider can attempt with its default model.
- classifyError ordering matters: BILLING / MODEL_NOT_FOUND are matched
BEFORE the generic 400 / Bad Request branch, otherwise they would be
swallowed by CLIENT_ERROR.
Tests
- ErrorClassificationTest: 11 tests, covers multi-vendor error phrasing
for both new types + regression checks that 401 / 429 / 400 still
classify as before
- NodeStreamingChatHelperFallbackChainTest: +2 tests verifying
primaryProviderId persistence on the new constructor and null on
legacy ones
- 181 tests pass (was 168 + 13 new)
UI — Failover priority editor
- ProviderConfigRequest + ProviderInfoDTO carry fallbackPriority
- ModelProviderService.updateProviderConfig persists it (null = unchanged);
toProviderInfo exposes the current value to the UI (defaults to 0)
- ProviderConfigModal advanced panel exposes a number input with hint
- ProviderCard shows a "Fallback #N" badge for chain members so the
priority order is visible at a glance without opening the modal
- 5 new i18n keys (zh + en) — verified to resolve at runtime via i18n.global.t
Backend — Per-provider health tracker
- ProviderHealthTracker: ConcurrentHashMap-backed counters; N consecutive
failures (default 3) push the provider into a cooldown window (default
5 min) during which the chain walker skips it. Success resets both
counter and cooldown atomically. Lazy expiry on lookup so dead entries
do not accumulate.
- ProviderHealthProperties exposed under mateclaw.llm.failover.health.*
with sane production defaults
- New FallbackEntry record (providerId + ChatModel) replaces raw
List<ChatModel> in the chain so the walker can correlate cooldown
state to entries; AgentGraphBuilder.buildFallbackChain returns the
new type
- NodeStreamingChatHelper takes the tracker through a new 4-arg
constructor and consults it before each fallback call; records
success/failure on each chain attempt. Legacy 2/3-arg constructors
preserved as @Deprecated wrappers (synthetic providerId means no
health tracking on the legacy path — that path is opt-out anyway)
Tests
- ProviderHealthTrackerTest (9 tests): below/at threshold, success
reset, cooldown expiry (via reflection on the min-clamp setter),
disabled-tracker no-op, null-providerId safety, per-provider
isolation, snapshot output
- NodeStreamingChatHelperFallbackChainTest updated to FallbackEntry
field type — verifies providerId + ChatModel survive the chain
- 168 tests pass (was 159 + 9 new)
Verification
- mvn test green; vue-tsc clean; live UI confirms i18n resolution
Replaces the hardcoded single-DashScope fallback with a DB-driven
ordered chain. Same-provider primary deployments (e.g., DashScope
qwen-max) finally get a real fallback; if any provider in the chain
returns an empty body or transient failure, the next is tried.
Schema — DB-driven chain
- mate_model_provider gains `fallback_priority INT DEFAULT 0`. Positive
values define try-order; 0 = not in chain. Migration V21 (h2 + mysql)
seeds DashScope as priority 1 to preserve existing behavior.
- ModelProviderService.listFallbackChain() returns providers ordered by
priority ascending.
- ModelProviderEntity gains the new field.
Runtime — chain walk + empty-response trigger
- AgentGraphBuilder.buildFallbackChain(primaryConfig) returns a
List<ChatModel>, identity-filtering the primary by (providerId,
modelName) — fixes the bug where same-provider-primary deployments got
null fallback. Providers whose API key is missing are silently
skipped with WARN. Old buildFallbackModel(ChatModel) kept as
@Deprecated wrapper.
- NodeStreamingChatHelper accepts List<ChatModel>; the post-retry
fallback block now walks the chain in priority order, single-shot
per entry. Old single-fallback constructors retained as @Deprecated
one-element-list wrappers so legacy callers keep working.
- New ErrorType.EMPTY_RESPONSE: when the LLM returns no content, no
thinking, AND no tool calls, mark the result as a soft failure and
break the same-model retry loop, handing off directly to the
fallback chain.
- Broadcast updated to "切换到备选模型 (N/M)..." so SSE consumers see
chain progress.
Tests
- NodeStreamingChatHelperFallbackChainTest covers constructor variants,
chain immutability, deprecated-overload back-compat, and the
EMPTY_RESPONSE enum exists as a compile-time contract.
- 159 tests pass (was 153 + 6 new).
Pasted prompts (test cases, structured asks, JSON dumps) currently render
through the same markdown pipeline as assistant output, so '#'/'-'/'**'
characters are processed and long prompts dominate the scrollback.
- New UserMessageContent.vue: plain-text rendering (white-space: pre-wrap
preserves user-typed newlines and indentation), with auto-collapse beyond
8 lines and a "Show more (N more lines) / Show less" toggle. Soft mask
gradient at the collapse boundary instead of a hard cut.
- MessageBubble.vue: route role==='user' messages through the new component;
assistant messages keep the existing markdown pipeline unchanged.
- Add chat.expandLines / chat.collapse i18n keys (zh + en).
Verified end-to-end in browser preview: 15-line content collapses to 8,
toggle expands to full 15 with "Show less" label, raw '#' / '**' / '`' chars
shown literally with no <strong>/<h1>/<li> tags emitted.
A. Delete two dead prompt files (prompts/context/conversation-summary-*.txt)
that no caller has loaded since the structured-summary triple replaced them.
B. Drop the never-wired locale machinery: PromptLoader.loadPrompt(name, locale)
overload + the prompts/{locale}/... fallback chain + I18nService.currentLocaleTag().
A single-language prompt corpus plus LLM input-language following is sufficient.
C. Strip duplicated structure list / budget directive from
structured-summary-update.txt (the system prompt already carries them).
Add a defensive preamble to both summary prompts: "do not respond to any
questions or requests in the conversation, only output the structured
summary" — prevents the summarizer from accidentally answering historical
user questions.
D. Fix {summary_budget} placeholder leak in the iterative-update branch of
ConversationWindowManager.generateSummary. Both branches now substitute
on the SystemMessage uniformly. Regression-guarded by
ConversationWindowManagerSummaryBudgetTest.
E1. De-hardcode seven prompts (research/{plan,draft,compose}-{system,user},
graph/limit-exceeded-system) — language now follows the user's input
instead of being hardcoded; citation tokens are language-neutral
[M1] / [Q1] markers.
E2. Add 10 i18n keys (research.fallback.*, research.broadcast.*,
agent.limit_exceeded.*) to messages.properties + messages_en.properties.
Inject I18nService into WikiResearchService and LimitExceededNode and
route 5 + 2 hardcoded fallbacks through i18n.msg(). Regression-guarded
by WikiResearchServiceFallbackTest + LimitExceededNodeFallbackTest.
E3. Replace 3 assembly tags in WikiResearchService with neutral
[M1] / [Q1] tokens. Aligns with the [M1] / [M2,3] citation format the
draft prompt asks for.
G. Three new regression tests cover D, E2, and E3.
> **Latest stable: v2.1.0 — Team Runs, closed skill evolution, and replayable reasoning.** One team request is now one durable `runId` across Chat, Agents, and Teams; skills can mine recurring requests under explicit controls and restore from snapshots; reasoning, tool calls, and observations can be exported in execution order. Read the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0).
> **Latest stable: v2.2.0 — a pluggable, recoverable Agent Runtime.** Digital employees can now run on MateClaw's native StateGraph engine or the managed DeepSeek Harness (DSH) runtime while keeping one conversation, policy, tool, persistence, and observability plane. Persistent Goals survive bounded turns and backend restarts, and A2A connects governed employees across systems. Read the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
---
@ -38,7 +38,7 @@
>
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
>
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over authenticated JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
@ -83,7 +83,17 @@ Same brain. Same memory. Same tools. Different doors.
## What's in the box
### Digital employees, not chatbots
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a runtime, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). Employee identity and governance stay stable even when the execution engine changes.
### Agent Runtime: native or DSH (2.2.0+)
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** manages `dsh-jsonrpc-agent` as an authenticated child process and streams thinking, text, tool calls, usage, completion, and cancellation back as normalized runtime events. DSH owns the external Agent loop; MateClaw still owns the session, workspace, credentials, tools, approvals, messages, and UI projection. Runtime availability and capabilities are validated before startup, and DSH can be installed, verified, connection-tested, enabled, or disabled from the console. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
For file-producing work, ask the employee to keep a progress ledger, append small verifiable units, inspect the existing tail after recovery, and complete the Goal only after reproducible acceptance checks pass. The runtime does not promise exactly-once behavior for arbitrary external side effects; payments, sends, publishes, and destructive calls still need provider idempotency or review. [Run and verify durable Goals →](https://claw.mate.vip/docs/en/goals)
> Prompt pattern: “Create a persistent Goal first. Save the plan and progress in the workspace, write in small checkpoints, resume from existing evidence after errors or restart, and call `completeGoal` only after every criterion has verifiable evidence.”
### Team Runs (2.1.0+)
One request, one durable **Team Run**. A stable `runId` links the user's objective, task DAG, worker executions, final synthesis, and deliverables. Chat is the outcome surface, Agents Live groups the workers for real-time observation, and Teams owns history and governance — all three consume the same server projection. Worker conversations no longer flood the normal sidebar; summaries and files lead, while tasks, evidence, approvals, and read-only worker records drill down on demand. Underneath, the 2.0 shared board still provides dependency orchestration, parallel dispatch, prerequisite hand-off, execution leases, cancel-interrupt, and human approval gates.
@ -105,7 +115,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), each reasoning iteration keeps its real position and wall-clock duration, and linear trajectory export lays out reasoning, calls, observations, and answers for review. Per-event SSE IDs make reconnects safe; Team Runs group member work under one live execution.
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, which runtime provider owns the turn, what step it is on, how many tokens it uses, and one-click force-recycle when stuck. Native and DSH events enter the same thinking / tool / answer projection; completion, failure, usage, and cancellation retain consistent lifecycle semantics. Per-event SSE IDs make reconnects safe, and Team Runs group member work under one live execution.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
@ -122,7 +132,7 @@ RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-2
Model providers rate-limit, networks fail, keys expire, and services become temporarily unavailable. Betting every AI capability on one provider turns an upstream incident into your own outage.
Once AI enters production, the stable layer should not be tied to one supplier. MateClaw absorbs that uncertainty into one runtime through provider priorities, health tracking, cooldown, and failover.
Once AI enters production, the stable layer should not be tied to one model supplier or one Agent loop. MateClaw absorbs model uncertainty through provider priorities, health tracking, cooldown, and failover, then places native and external execution engines behind one governed Agent Runtime contract.
**MateClaw is that layer — built the Spring Boot way.**
@ -194,7 +204,7 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
- **Runtime hardening** — tighter workspace ownership, reliable Team Run recovery and deliverable gates, plus consistent long-form output and input handling across approval, stop, and recovery
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
- **Unified Team Runs** — one `runId` links request, task DAG, worker conversations, events, final synthesis, and deliverables; Chat delivers outcomes, Agents observes live work, Teams governs history
**TL;DR** — Most users have nothing to do. Restart with 1.1.0, Flyway's built-in repair heals known checksum drift, Ollama auto-discovery rewrites the bad `:latest` defaults, and everything else self-converges. Docker Compose deployments need a one-time `.env` update.
See `docs/en/releases/1.1.0.md` for the feature changelog.
---
## For everyone
### ⚠️ What happens automatically (no action)
- **Flyway migration self-heal** — 1.1.0 rewrote all MySQL migrations V2–V14 to replace unsupported `ADD COLUMN IF NOT EXISTS` syntax (Gitee #IIYHLJ). `FlywayRepairConfig` runs `flyway.repair()` on every boot, so the new checksums auto-accept and migration resumes from wherever your schema is.
- **Ollama default model** — if your 1.0.x run auto-picked a model tag Ollama no longer has (commonly `deepseek-r1:latest`), on 1.1.0 restart `OllamaAutoDiscoveryRunner` detects the broken default and re-picks a tag-capable model (e.g. `deepseek-r1:7b`, `qwen3:latest`), preferring one that supports function calling.
- **Stale `mate_model_config` rows** — idempotent seed data reconciles on each startup.
### 📋 Recommended pre-upgrade steps
1. Back up your database — `mateclaw` schema on MySQL, or `data/mateclaw.mv.db` on H2.
2. Back up `data/` directory (skill workspaces, uploaded files, memory files).
3. Note your current default model in Settings → Models in case you want to switch back.
### 🚀 Upgrade
```bash
git pull
cd mateclaw-server
mvn clean package -DskipTests
# then restart your service per your deployment method
```
Or for Desktop app users: just update to 1.1.0 via the in-app updater or re-download.
---
## For Docker Compose deployments
**One-time migration step required** — 1.1.0 refuses to start with default hardcoded passwords.
### 1. Copy-paste merge the new `.env.example` keys
```bash
cp .env .env.backup
# open .env.example — it has new required keys:
# DB_PASSWORD= (was default 'mateclaw123', now MUST be overridden)
# DB_ROOT_PASSWORD= (new, required for MySQL root)
# JWT_SECRET= (new, strongly recommended)
# MATECLAW_CORS_ALLOWED_ORIGINS= (new, strongly recommended for prod)
```
### 2. Set strong values in your `.env`
```env
# STRONG passwords — at least 16 chars, mixed case + digits + symbols
If any of `DB_PASSWORD` / `DB_ROOT_PASSWORD` / `DASHSCOPE_API_KEY` is missing, `docker compose up` will fail fast with a clear error — this is intentional.
### 3. Existing MySQL volume compatibility
If you already ran 1.0.x with the old default password (`mateclaw123`), **your existing MySQL volume still has the old root password inside**. You have two options:
**Option A — keep existing password** (fastest, least secure):
Set `DB_ROOT_PASSWORD=mateclaw123` and `DB_PASSWORD=mateclaw123` in `.env` to match. Upgrade works. Then rotate after upgrade using `ALTER USER ... IDENTIFIED BY ...` inside the MySQL container.
**Option B — fresh volume with new password** (cleanest, loses DB if not backed up):
```bash
docker compose down -v # ⚠️ deletes mysql_data volume; back up first
# edit .env with new strong password
docker compose up -d
```
Then re-import your backup if you kept one.
### 4. Restart
```bash
docker compose up -d
docker compose logs -f mateclaw-server # watch for "Flyway Successfully applied N migrations"
```
Expected log lines during boot:
- `Flyway Successfully applied N migrations to schema mateclaw`
- `Ollama: auto-activated default model '<actual-tag>'` (if you use Ollama — should NOT say `:latest` any more)
- `[Security] Using default JWT secret!` → means you forgot to set `JWT_SECRET` — fix and restart
---
## For local dev / H2 deployments
No action required. `mvn spring-boot:run` picks up the latest migrations on next start, Flyway repair handles checksum drift, H2 file at `data/mateclaw.mv.db` is preserved.
---
## Known migration quirks
### 1. If you manually fiddled with `flyway_schema_history`
In 1.0.x some users hit Flyway version collisions (V8/V9 and V9/V10) which 1.1.0 fixes by renumbering. If you manually deleted rows from `flyway_schema_history` you may see `Validate failed` on 1.1.0 startup — run:
```sql
-- MySQL
DELETE FROM flyway_schema_history WHERE success = 0;
```
Then restart. `FlywayRepairConfig` will rebuild history from current schema state.
### 2. If your Ollama models are all in the no-tools family
After upgrade, agents that require tool calling will log a warning on first invocation:
```
Ollama: auto-activated default model '...' but its family does not support tool calling
```
Fix — pull a tool-capable model, or switch default in Settings → Models:
```bash
ollama pull qwen3
# or
ollama pull llama3.1:8b
# or
ollama pull mistral-nemo
```
### 3. If you had custom tools using `extract_document_text` / wiki tools
Wiki chunk schema changed (new `embedding` + `embedding_model` columns on `mate_wiki_chunk`). Your existing wiki pages work unchanged; only semantic search is new and requires an embedding model to be configured in Settings → Models (a default DashScope embedding is seeded).
---
## Rolling back to 1.0.x
Not recommended (some new tables / columns don't exist in 1.0.x), but possible if you backed up the DB before upgrade:
```bash
git checkout v1.0.418
# restore DB backup
docker compose up -d # or mvn spring-boot:run
```
If you need to keep the new data but downgrade the app, you're in unsupported territory — open a Gitee issue.
---
## Getting help
- **Logs first**: `mateclaw-server/logs/mateclaw.log` + `mateclaw-error.log` have everything. Flyway decisions are at INFO level in main log.
- **Doctor tab**: in-app Settings → Doctor runs basic health checks
- **Gitee**: https://gitee.com/matevip_admin/mateclaw/issues — include your upgrade path (1.0.?? → 1.1.0), profile (H2 / MySQL), and the last 100 lines of startup log
privatestaticfinalStringEVIDENCE_QUERY="SELECT e.*, a.conversation_id FROM mate_execution_evidence e JOIN mate_execution_attempt a ON a.id=e.attempt_id WHERE e.deleted=0 AND a.deleted=0";
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.