Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.
Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.
When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.
Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
The workstations page was written against snake_case field names the
gateway never served. Go's json decoder drops unknown members silently,
so create requests arrived with an empty WorkstationKey and were rejected
with "workstationKey is required", while list rows read undefined and
rendered a blank key, "backend.undefined" and "Invalid Date".
Align the TypeScript contract on the camelCase shape that both the WS
handler and store model already use:
- Rename Workstation, CreateWorkstationParams and every field read on the
workstations page to workstationKey/backendType/createdAt/updatedAt.
- Nest update params under `updates`; a flattened body decoded to an empty
map and was rejected with "no updates provided".
- Replace the Identity File input, which mapped to a field SSHMetadata does
not accept, with a private key or password credential path.
- Add the image field required by DockerMetadata and map the container name
onto host and the daemon endpoint onto socketPath, so Docker workstations
can be created at all.
- Extract the payload builder so the wire contract is unit-testable, and
move validation strings into the en/vi/zh catalogs.
Covered by tests asserting the create payload shape and list rendering
against a verbatim gateway response body.
Co-authored-by: Eddy Lockwood <doakythanh@gmail.com>
SSRF protection resolves a hostname and judges the resulting IP. That
model assumes DNS resolution describes where the traffic actually goes,
which stops being true behind a TUN/fake-IP proxy: every query is answered
with a synthetic address out of a reserved range, and the proxy then
routes that address to the real public host. The IP is a handle, not a
destination.
In that environment web_fetch rejects ordinary public sites — observed
with news.sina.cn resolving to 198.18.0.236 — and no configuration can
fix it, because the block list is compiled in. The agent then burns
iterations retrying URLs that can never succeed.
GOCLAW_SSRF_ALLOWED_CIDRS lets an operator name the ranges their proxy
hands out. Empty by default, so nothing changes for deployments that do
not set it, and the accepted and refused entries are both logged at
startup — this widens what LLM- and admin-supplied URLs can reach, so it
should be visible.
Ranges an SSRF actually targets can never be allowlisted: link-local
(including cloud metadata at 169.254.169.254), multicast and unspecified
are refused at parse time, in either direction, so neither an exact entry
nor a wider range that swallows one gets through.
Applied inside isBlocked rather than only in validate() because
NewSafeClient re-checks the pinned IP at dial time through the same
function — relaxing just the pre-flight check would pass validation and
then fail to connect.
internal/tools carries its own private-range list for web_fetch and
web_search, separate from this package and not identical to it. It has to
consult the same setting, or relaxing one gate leaves the other rejecting
the very traffic the operator permitted. Unifying the two lists is left
alone here; it is a wider change than this one.
Co-authored-by: Conner Mo <connermo@ConnerdeMacBook-Pro.local>
Added error handling for input reading in the agent chat client and for database row reading in the doctor commands, ensuring that errors are reported clearly to the user.
A reasoning model emits nothing at all while thinking, so the wait for the
response's FIRST byte — not any streaming timeout — decides whether its answer
is reachable. At 180s a large-prompt reasoning request routinely lost that race:
the gateway reported "http2: timeout awaiting response headers" while the
router's own usage log showed the same request completing normally moments later
(promptTokens=77194 -> completionTokens=2789). The request was healthy; only the
deadline was short.
Raise it to 300s. The value is deliberately modest rather than generous because
it is multiplied twice downstream: RetryDo wraps every provider call with
Attempts: 3, and a workflow step requeues up to MaxTaskDispatches times on top
of that, so 300s is already ~45min of worst-case wall clock per step.
Removing the timeout entirely was considered and rejected. Agent run contexts
come from scheduler queue.go via context.WithCancel, so they carry no deadline —
this transport timeout is the only thing bounding a request that never answers.
Without it a silently dead connection would hold its scheduler lane until the OS
TCP keepalive expired, and workflow requeue could not rescue it because requeue
only fires when a run ends. The doc comment claiming the design could "rely on
ctx deadlines" was wrong and is corrected here.
Tests lock the sizing from both sides (must exceed the latency that failed, must
not exceed the ceiling the retry math was sized against) and assert the retry
multiplier itself has not drifted.
Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
The extractive fallback runs when an LLM memory flush fails, so it is the last
thing standing between a session and losing its accumulated decisions. Its cue
patterns were English-only ("decided to", "prefer", "the API is"), which makes it
effectively inert for a session conducted in another language: it still returns a
non-empty result, so nothing looks broken, but the result is noise.
Measured on 195k characters of real Vietnamese session text: 9 decision matches
(all of them stray English fragments), 0 preferences, 0 technical facts — about
97% of what it "saved" was incidental file paths. After adding Vietnamese cues the
same corpus yields 21 decisions and 6 preferences on the largest session.
Adds parallel Vietnamese patterns for the same three categories and merges them
into the existing results with deduplication, so English extraction is unchanged
and mixed-language sessions get both. Tests cover Vietnamese-only,
English-only, and mixed histories, plus a guard that ordinary Vietnamese
narration is not misread as a decision or a preference.
Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
FinalizeStage forwards state.Prune.MidLoopCompacted into MaybeSummarize so a
mid-loop compaction gets persisted (TruncateHistory + IncrementCompaction)
instead of being thrown away and redone next turn. Nothing asserted the flag
value: TestFinalizeStage_MaybeSummarize_Called binds it as `_ bool`, so
hardcoding `false` at the call site keeps the whole suite green while silently
restoring the per-turn re-compaction loop and stalling the cumulative
compaction count episodic depends on.
Adds two cases driving the real NewDefaultPipeline().Run():
- a history large enough to force prune-stage compaction must reach
MaybeSummarize with midLoopCompacted=true
- a small history must compact nothing and report false, so the flag cannot
simply be pinned true
Mutation-verified: with the finalize call site hardcoded to false the new test
fails while the pre-existing pipeline and agent suites still pass.
Test-only change.
Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
The v3 pipeline compacts session history mid-loop (prune_stage +
final-request guard) but only mutates the run's message buffer, never the
session store. Each turn reloads full history and re-compacts from scratch:
message_tokens climb 129k->156k across turns while every turn compacts back
down to ~60k. The lossy compaction differs per run, degrading the agent.
The same missing persistence stalls episodic memory: the cumulative
compaction count never advances, so the episodic worker's idempotency key
(sessionKey:count) is pinned and every cycle after the first is skipped.
Observed on live traffic: 8 run.completed since deploy, 0 new episodic.
Fixes, all reusing existing machinery (no new store methods, no migrations):
- Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching
the legacy v2 path) instead of the per-run counter that resets to 0.
- Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into
maybeSummarize; under pressure it lowers the trigger to a unit-aligned
threshold (compactionInputCap - overhead, same MaxRequestShare the guard
uses) so the compaction is PERSISTED via the existing TruncateHistory +
IncrementCompaction path. Defensive floor prevents over-compaction on
pathological config; tool-result-only bloat still skips (history-only).
- Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup
key advances per compaction cycle instead of swallowing rapid same-session
turns within the 5m TTL.
Tests: episodic compaction, maybe_summarize pressure, request budget.
go build (PG + sqliteonly), go vet, go test -race all green.
SetLastPromptTokens received TotalUsage.PromptTokens — the sum of prompt
tokens across every think→act→observe iteration of a run — instead of the
final iteration's own usage. A tool-heavy run inflated the stored value by
the iteration count, so the sessions list showed e.g. 901K "context used"
against a 258K window, and maybeSummarize over-triggered compaction once
the inflation exceeded the 40% overhead clamp (observed: 83 compactions
on a 4-message session).
Track the final iteration's usage separately (ThinkState.LastUsage) and
persist its actual context size: prompt tokens + cached segments (Anthropic
reports input_tokens excluding cache read/creation) + output tokens (the
final reply joins history, so it occupies the next request's prompt). The
run-cumulative total still feeds AccumulateTokens for billing.
A single dispatch goroutine delivered every reply in the process — every
channel, every user — each Send blocking the next. One media upload of a
few seconds froze all outbound traffic for that long, and sustained
throughput was capped at one Send round-trip regardless of lane capacity.
The ordering that loop actually protected is per-conversation: a run emits
block replies, retry notices and a final answer to one ChatID, and those
must arrive in that order. A global order was never required.
Dispatch now shards on channel+ChatID. A conversation always maps to the
same shard and is delivered serially there, so the ordering guarantee is
unchanged, while unrelated conversations proceed in parallel.
GOCLAW_OUTBOUND_SHARDS tunes the worker count (default 8).
Temp media needed a real fix to go with it. Serial dispatch deduped it
implicitly — the first delivery removed the file, and a later message
carrying the same path found os.Stat failing and skipped it. Across
concurrent shards that check alone is a TOCTOU race, so claims are now
taken atomically and released after the send.
Also makes the pool limits that bound this path configurable, defaults
unchanged: GOCLAW_PG_MAX_OPEN_CONNS / GOCLAW_PG_MAX_IDLE_CONNS, and
GOCLAW_HTTP_MAX_IDLE_CONNS / GOCLAW_HTTP_MAX_IDLE_CONNS_PER_HOST. The
per-host limit binds when one provider takes nearly all traffic: every
request past it pays a fresh TCP+TLS handshake, which a deployment
raising LaneMain needs to be able to lift.
Task locking (AssignTask/ClaimTask) applies to every active team with no
version check, but the recovery/stale/followup queries only ran for teams
whose settings.version >= 2. Any team without that flag accumulated
permanently-stuck in_progress tasks: locks were set on dispatch but never
reclaimed once they expired, so the task ticker skipped them forever
(including the startup force-recover).
Align the maintenance scope with the locking scope by filtering recovery,
stale-marking, and follow-ups to active teams regardless of settings
version. Applies to both the Postgres and SQLite backends; renames the
shared JOIN constant v2ActiveTeamJoin -> activeTeamJoin to match.
The dashboard /tts page writes to system_configs[tts.<provider>.voice],
but the LLM-invoked tts tool was only checking args > agent OtherConfig
> builtin_tool_tenant_configs[tts].default_voice_id. Two different
storage locations → the user's chosen voice was ignored, Edge defaulted
to en-US-AriaNeural even when "HoaiMy" (vi-VN-HoaiMyNeural) was set
correctly in the dashboard.
Add system_configs as a 4th-level fallback so the dashboard becomes
the single source of truth.
- TtsTool gains SetSystemConfigStore(s) setter
- resolveVoiceAndModel takes providerName + looks up
tts.<provider>.voice/model when no higher-precedence source set
- effectiveProvider resolved BEFORE voice/model so the right key is hit
- Wired at gateway boot in cmd/gateway.go (after pgStores ready)
- 4 unit tests covering: fallback, arg precedence, no-store, empty provider
Verified against production trace 019e6036-44bb-703b-85fa-dee34f7ab2c0
where the tts tool was called with provider=edge, no voice arg, and
defaulted to English instead of the configured Vietnamese voice.
* feat(audio): add openai_compat TTS/STT provider for self-hosted endpoints
Adds a provider pair speaking the OpenAI audio wire format against any
compatible endpoint (gpu-manager, Speaches, vLLM, LocalAI, llama.cpp,
Ollama), so a self-hosted engine no longer needs a bespoke HTTP shim
implementing goclaw's proprietary /transcribe_audio contract.
Kept distinct from the openai package on purpose. Both speak the same
wire format, but "openai" carries api.openai.com semantics, and
audio.IsVoiceCompatible applies OpenAI's voice allowlist to any provider
by that name — FilterVoiceForProvider then silently substitutes "alloy"
for anything outside it. A self-hosted voice ID such as Piper's
"fr_FR-gilles-low" would be replaced with no error and no log line, and
the caller would hear the wrong language. Providers without validation
rules pass through untouched, which is the correct behaviour for an
engine whose voice namespace is its own.
Other deliberate departures from the openai package:
- api_base is required. There is no public endpoint to fall back to, and
defaulting to api.openai.com would send self-hosted traffic to a vendor.
- An empty api_key omits the Authorization header rather than sending an
empty Bearer token; self-hosted engines commonly have no auth.
- Empty voice/model are omitted from the request instead of sent blank,
since some engines resolve the model from the requested voice.
- The multipart filename is never blank: several engines type the audio
by extension rather than Content-Type and reject what they cannot type.
Endpoint error bodies are preserved in the returned error so a format
mismatch (e.g. an engine that cannot encode mp3) is diagnosable.
* feat(audio): wire openai_compat TTS/STT provider into gateway config
Adds tts.openai_compat config block and registers both providers at
startup. api_base is the enable switch rather than an API key, since
self-hosted endpoints commonly have no auth and there is no vendor
default to fall back to.
The STT chain is now built from the providers that actually registered
instead of being hardcoded to {elevenlabs, proxy}. Transcribe skips
unregistered names with a warning on every call, so a static chain
naming absent providers is log noise on the hot path. openai_compat goes
first when present: it is an explicit operator choice and keeps audio on
the local network. "proxy" stays last, as BridgeLegacySTT registers it
later per channel.
Behaviour is unchanged when tts.openai_compat.api_base is unset: the
chain resolves to {elevenlabs, proxy} exactly as before.
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Bitrix24 was the only channel that never showed the agent's progress as an
emoji reaction on the user's message (Telegram/Slack/Feishu/Discord/... all do).
Two things were missing: the channel didn't implement channels.ReactionChannel,
and its inbound metadata omitted the generic "message_id" key the gateway
consumer reads to thread status/streaming events back to a message.
- handle.go: set meta["message_id"] = MESSAGE_ID (every other channel does this;
without it the run has no message id to react to). Kept the existing
bitrix_message_id key for reply-link routing.
- reactions.go (new): implement OnReactionEvent + ClearReaction. Map agent
status → a Bitrix v2 reaction code (thinkingFace / fire / whiteHeavyCheckMark /
crossMark / ...), debounce intermediate updates (terminal states bypass), and
replace the previous reaction via imbot.v2.Chat.Message.Reaction.delete +
.add (Bitrix has no in-place replace). REACTION_ALREADY_SET is treated as
success. ReactionLevel off/minimal/full already existed in the channel config.
- channel.go: add the per-message reactions state map + compile-time assertion.
Tests: full flow (add → delete+add on status change), minimal skips
intermediate, off/invalid/unknown are no-ops, ClearReaction removes current.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
When an agent's DM/Group policy is "pairing (require code)", an unpaired
Bitrix24 sender got silence: the channel only had a Phase 03 stub
(logPairingNeeded) that logged "pairing required (Phase 07 will send pairing
reply)" and dropped the message — no code was ever generated or sent, so an
admin had nothing to approve. Bitrix24 was the only channel missing this;
Discord/Zalo/WhatsApp/Slack/Feishu already send a pairing reply.
Replace the stub with sendPairingReply, mirroring the other channels:
- RequestPairing() to generate the code (keyed by senderID for DMs,
"group:<chatID>" for groups so it matches CheckGroupPolicy.IsPaired).
- Render the shared systemmessages.KeyPairingAccountRequired message
(code + how to approve).
- Send it back into the chat via the v2 API (imbot.v2.Chat.Message.send),
consistent with the welcome / OAuth-invite messages — not the legacy
imbot.message.add v1.
- Keep the per-sender debounce so a spammy sender can't flood the chat.
Tests: send fires on the v2 endpoint with the code, no-op without a pairing
service, and the second call within the debounce window is suppressed.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Follow-up to #1443. For a media-only quoted message, im.dialog.messages.get
frequently returns a blank type/name (observed live for a voice note), so the
reply note fell back to the generic "một tệp" even though the file was correctly
downloaded and tagged <media:audio>.
Build the note AFTER downloading and derive the kind label from the downloaded
file's MIME (reusing classifyMediaType), so it reads "một tin nhắn thoại" /
"một hình ảnh" / "một video" to match the <media:*> tag the agent receives.
Falls back to the Bitrix file type, then "tệp", when no download is available
(e.g. oversized). Also adopts the downloaded filename when messages.get omitted it.
Adds tests for the MIME-preference path and mediaKindVN.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Bitrix24 ships the quoted message inline on reply events via REPLY_MESSAGE,
but the channel parsed a non-existent REPLY_TO_MESSAGE_ID field, so the agent
never saw what a user was replying to. Recognize replies and feed their
context to the agent (Hybrid design):
- events.go: parse data[PARAMS][REPLY_MESSAGE][ID|AUTHOR_ID|MESSAGE] (form+json)
with PARAMS[REPLY_ID] fallback into EventParams.ReplyMessage; drop the dead
ReplyToMID field + bitrix_reply_to_mid meta.
- reply_context.go (new): resolveReplyContext prepends a short note and, for
media-only originals, re-injects the quoted file(s) through the same download
pipeline as a direct upload.
text quote -> sanitize BBCode + truncate, no API call.
media quote -> im.dialog.messages.get (works for TYPE=bot bots, unlike the
blocked imbot.v2.Chat.Message.get) to recover the attachment, bounded by
media_max_mb; note-only fallback on any fetch/download failure.
- handle.go: wire resolveReplyContext into message assembly (prefix + extra media).
Covers text, image, audio, file, video quotes. Non-reply events are a no-op.
Adds unit tests for parse (form+json), sanitize, file-id extraction, result
parsing, the messages.get fetch, and both resolve branches.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
* feat(mcp): expand CRUD MCP server to near-complete CLI parity
Closes the CLI-vs-MCP tool coverage gap identified by auditing every
`goclaw` CLI command against the existing goclaw_* MCP tool set. Adds
skill grant/revoke, agent skill pin/unpin, and full CRUD/inspection
surfaces for memory, knowledge graph (including dedup/merge/prune),
tenants, providers, LLM traces, channel contacts, pending messages,
audit activity, system config, tenant storage (list/size/delete/move),
scoped agent config export/import, secure-CLI binary registry, and a
DB-backed health check.
Deliberately out of scope, documented inline where relevant:
- `goclaw credentials`: confirmed CLI-local (~/.goclaw/config.yaml +
keychain), no server resource to wrap. goclaw_secure_cli_binaries_*
covers the closest real, previously-uncovered server resource instead.
- Full tar-archive agent export/import (KG + workspace files): the CLI's
version streams a multi-section archive with progress events, a shape
that doesn't map to a single MCP tool call. Config + context files
(the portable "brain") is covered.
- `kg extract` (LLM-driven text extraction): goclaw_kg_ingest accepts
the same Entity/Relation shapes the extractor produces, so a caller
can run extraction itself and hand off the result.
Wires 8 new store dependencies (Memory, KnowledgeGraph, Tracing,
Contacts, PendingMessages, Activity, SystemConfigs, SecureCLI) through
gateway.Server setters -> cmd/gateway.go -> CRUDDeps, following the
existing Providers/Tenants pattern. Storage and secure-CLI-binary
handlers duplicate internal/http's path-escape/symlink-hiding
validation logic (documented inline) since internal/http already
imports internal/mcp and the reverse would cycle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(mcp): return sessionKey in goclaw_chat_send response for persistent conversations
The goclaw_chat_send tool creates a new session internally when sessionKey is
empty, but never returned the key to the caller. This prevented using
goclaw_chat_history to fetch previous messages in the session.
Add SessionKey field to ChatSendResult so callers can:
1. Start a new agent chat without providing sessionKey
2. Receive the sessionKey back in the response
3. Use that sessionKey for follow-up messages and history queries
Fixes the training loop pattern: start chat → get sessionKey → call
goclaw_chat_history with that key → iterate skill based on actual failures.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(mcp): add timing diagnostics to goclaw_chat_send for timeout troubleshooting
Log request arrival, processing duration, and errors with millisecond precision.
Helps identify whether timeouts occur at MCP client→goclaw, goclaw→ollama, or
during agent execution. Critical for production debugging.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(gateway): add http.Server timeouts for defensive timeout handling
Set explicit timeouts in http.Server:
- ReadTimeout: 1h (allow large uploads, long-running agent operations)
- WriteTimeout: 1h (allow streaming responses to slow clients)
- IdleTimeout: 30s (close idle keep-alive connections quickly)
Provides defense-in-depth when Nginx/Traefik timeouts are misconfigured.
Matches Nginx timeout (3600s) to prevent race conditions.
Timeout chain: traefik 3600s = nginx 3600s = goclaw 3600s
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>