Media name preservation already ships: the REST schemas expose a name on
image and video content, and kap-server reads imageUrl.name and videoUrl.name
when projecting messages and prompt content. The kosong part types never
declared the field, so it survived only because object literals keep excess
properties through an assignment. Consumers that read these types directly
cannot see the name at all.
Declare it where the other optional part fields live. No runtime change; the
existing media name projection tests continue to cover the behavior.
A malformed config entry (e.g. an unquoted dotted TOML key like
[models.kimi-k2.7-code]) parses into a nested object that lacks the
top-level model field. The v2 config schema marks model optional, so the
entry reaches profile matching with undefined, crashing getModels with
TypeError: Cannot read properties of undefined (reading 'toLowerCase').
matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, and
parseAnthropicModelVersion now return undefined/null for undefined input
instead of throwing.
Fixes#2981
Co-authored-by: zhaopeiwen.dev <zhaopeiwen.dev@bytedance.com>
* refactor: remove the legacy agent-core v1 package
Delete packages/agent-core (v1 engine) and migrate node-sdk fully onto
agent-core-v2:
- node-sdk owns its public contract (error protocol, KimiConfig schema
and file IO, process-global logging facade, telemetry client
interface); event and interaction types re-export from
@moonshot-ai/protocol or agent-core-v2
- createKimiHarness is now the single harness factory, backed by the
v2 client; the v1 SDKRpcClient is deleted
- CLI and vscode run agent-core-v2 unconditionally;
KIMI_CODE_LEGACY_FLAG and useAgentCoreV1 are removed
- the wire.jsonl replay fold moves into agent-core-v2's replayBuilder
(foldWireRecords), and the v2 journal now persists tool-call display
so replay restores diff/todo displays
- TUI engineV2 branches collapse to the v2 path
* test(vscode): retry temp-dir teardown in replay-resume integration test
* fix: fail fast on provider-filtered empty responses
An APIEmptyResponseError carrying finishReason 'filtered' (OpenAI
content_filter, Anthropic refusal) is deterministic: replaying the same
request re-triggers the provider safety filter. Both isRetryableGenerateError
implementations (kosong, agent-core-v2) treated every empty response as
retryable, so step retry replayed the doomed request the full 10 attempts
before the filter notice surfaced. Return non-retryable for filtered empty
responses in both engines; the error already carries the provider.filtered
code, so the turn fails immediately with the existing filter notice.
* fix: skip the compaction shrink-retry for filtered empty responses
Both full-compaction loops routed every APIEmptyResponseError into the
shrink-and-continue branch before isRetryableGenerateError was consulted,
so a filtered response was retried with shrinking input instead of failing
fast. Exclude finishReason 'filtered' from the shrink branch in both
engines; it now falls through to the retryability check and throws
immediately. Add end-to-end tests (real kosong generate over a filtered
think-only stream) asserting a single attempt with the history untouched.
---------
Co-authored-by: kimi-agent-bot <kimi-agent-bot@users.noreply.github.com>
Assistant messages carrying only tool calls were serialized without a
content key (JSON.stringify drops undefined), which strict
chat-completions validators such as LiteLLM reject with a 422,
permanently poisoning the session. Emit content: null for such messages
in both the kosong and agent-core-v2 converters, matching the shape
OpenAI responses use alongside tool_calls. The think-only empty-string
behavior in agent-core-v2 and both Kimi providers' deliberate content
omission are unchanged.
* chore: remove internal-network references from comments and test fixtures
- Reword two comments that named the internal free-tokens model
registration flow; the generic OAuth / managed wording carries the
same meaning
- Replace the qianxun.example placeholder base URL in google-genai and
runtime-provider tests with genai-gateway.example
- Swap realistic-looking LAN fixture IPs in the kimi web banner tests
(192.168.98.66, 10.8.12.216) for RFC 5737 documentation addresses
(192.0.2.66, 198.51.100.216)
* chore: retrigger CI (flaky kap-server searchRoute title-indexing test)
---------
Co-authored-by: bj456736 <bj456736@users.noreply.github.com>
* fix(agent-core-v2): preserve tool call extras in tool.call loop events
* fix(google-genai): keep trailing user text before function results when merging
---------
Co-authored-by: Selene <mahaoyang@corp.netease.com>
isImageFormatError missed the production phrasing "Unsupported image.
Please try another one." — the existing pattern requires a url/format/
type suffix, so the deterministic image rejection never triggered the
media-stripped resend, and the session failed on every later request.
Add a standalone-sentence pattern (punctuation- or end-terminated) to
both the kosong and agent-core-v2 classifiers, keeping the deliberate
boundary that count/size phrasings must not match.
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
* fix(kosong): fail fast on quota-exhausted 429 instead of retrying
A 429 caused by an exhausted account quota or insufficient balance
(Moonshot error.type "exceeded_current_quota_error", OpenAI
"insufficient_quota") can never succeed on retry, yet it was classified
as APIProviderRateLimitError and silently retried for the whole budget
(10 attempts, ~3 minutes of backoff) with no UI feedback — the session
appeared frozen on every request.
Introduce APIProviderQuotaExhaustedError, minted in
normalizeAPIStatusError from the structured body error.type/error.code
forwarded by convertOpenAIError, with billing-anchored message patterns
as a fallback for gateways that flatten the body to text. The new class
is excluded from isRetryableGenerateError (fail fast, even when a
retry-after header is present) and from isProviderRateLimitError (no
swarm requeue/suspend). toKimiErrorPayload and translateProviderError
map it to provider.api_error (retryable: false) instead of
provider.rate_limit, and classifyApiError reports it as
quota_exhausted in telemetry. agent-core-v2 mirrors the same fix.
Transient rate-limit 429s keep the existing retry, backoff, and
Retry-After behavior (verified end-to-end against a mock provider:
quota body fails after attempt 1/10; rate-limit body still walks the
full 10-attempt ladder).
Behavior changes to note: quota-failed swarm subagents now fail
instead of suspending indefinitely as "Rate limited...", and quota
errors cross the wire as provider.api_error rather than
provider.rate_limit.
* fix(kosong): classify quota exhaustion in OpenAI Responses stream errors
Responses response.failed / error SSE events carry no HTTP status and
were minted by errorFromOpenAIResponsesEvent as either a rate-limit
error (rate_limit_exceeded / embedded status_code=429) or a base
ChatProviderError — and the base class falls into the retryable
unclassified-failure fallback, so an insufficient_quota event still
burned the whole retry budget on the openai_responses path. Route the
event code and message through the same quota-exhausted check before
the rate-limit branch, in kosong and the agent-core-v2 mirror. Covers
all three entry paths (error events, response.failed, nested gateway
frames) since they share the single converter.
* style(agent-core-v2): drop inline comments per AGENTS.md header-only rule
agent-core-v2 comments live solely in the top-of-file block, never
beside functions or statements; the kosong twins keep the full
rationale.
* refactor(kosong,agent-core-v2): move quota-429 checks to vendor hook
Per review on #1857: the knowledge of how a backend signals quota
exhaustion is vendor-specific and must not run for every
OpenAI-compatible provider from the shared conversion layer.
- Add a convertError hook: ProtocolTrait.convertError in agent-core-v2
(single-value, last-declarer-wins, bound by composeOpenAIChatHooks /
composeAnthropicHooks / traitConvertError) and an equivalent optional
hook parameter on convertOpenAIError / convertAnthropicError. Bases
consult it with the raw failure (SDK error on HTTP paths, raw event on
the Responses in-stream path) after the abort guard, before their own
rules.
- Declare Moonshot's quota signals (exceeded_current_quota_error,
billing wordings) on the Kimi side: kimiOpenAITrait and
kimiAnthropicTrait in v2, the KimiChatProvider and KimiFiles catch
sites in kosong, all through the new classifyKimiQuotaError.
- Drop the options parameter from normalizeAPIStatusError and the
shared quota code/pattern tables: the contract layer keeps only the
vendor-neutral APIProviderQuotaExhaustedError type and its retry /
rate-limit / wire-mapping semantics.
- The OpenAI bases keep recognizing only OpenAI's own documented
insufficient_quota code (HTTP and Responses stream events) as
protocol knowledge of that wire.
Behavior: kimi and openai provider types classify exactly as before;
an unregistered vendor speaking Moonshot billing wordings through a
plain openai transport now stays a retryable rate limit by design.
* fix(kosong,agent-core,agent-core-v2): wire kimi quota hook fully
Follow-up to the second review round on #1857, all four findings:
- Kimi-over-Anthropic (legacy engine): AnthropicOptions gains the same
optional convertError hook as the OpenAI bases, threaded through
AnthropicStreamedMessage and every catch site, and the provider
manager's anthropic route now passes classifyKimiQuotaError for
provider type kimi — a quota-exhausted 429 over this transport
previously still burned the retry budget. classifyKimiQuotaError now
also walks error -> .error -> .error.error for the code/type, since
the Anthropic SDK keeps the full body on .error instead of hoisting.
- v2 telemetry: ApiErrorKind gains 'quota_exhausted' and
classifyApiError checks APIProviderQuotaExhaustedError before the
generic 429 branch, matching the legacy engine's reporting.
- Hook contract: converted ChatProviderErrors now pass through before
the vendor hook is consulted in convertOpenAIError /
convertAnthropicError (both engines), so the hook sees each raw
failure exactly once even when a stream-minted error crosses an
outer catch; tests assert the single consult.
- protocolTrait: the convertError member doc shrinks to the concise
style and the consult contract moves into the file header's
composition rules.
* test(kosong,agent-core,agent-core-v2): lock quota hook assembly paths
Third review round on #1857:
- Fix the v2 anthropic base header and AnthropicHooks doc still claiming
withThinking is the only hook.
- Drop the two remaining non-header JSDoc blocks in protocolTrait.ts per
the AGENTS.md header-only rule; the consult contract already lives in
the file header.
- Update the ProtocolTrait contract test to the seventeen-hook shape
(convertError included) and cover the traitConvertError binding.
- Add real-assembly regression probes: the v2 registry composes a
(kimi, anthropic) provider whose mocked SDK client throws a Moonshot
quota 429 and generate rejects with the non-retryable
APIProviderQuotaExhaustedError (a plain anthropic composition keeps
the same 429 retryable); the legacy ProviderManager routing test
asserts convertError is classifyKimiQuotaError on the kimi-anthropic
route and absent for plain anthropic; the legacy provider threads
options.convertError to its generate catch.
* test(kosong,agent-core-v2): cover KimiFiles quota 429 and drop stale docs
Fourth review round on #1857:
- Drop the AnthropicHooks member JSDoc (its content already lives in the
anthropic.ts and anthropicHooks.ts file headers) and fix the anthropic
contrib header still calling the hook set single-hook.
- Add the missing KimiFiles regression in both engines: a mocked files
client rejecting with a Moonshot quota 429 makes uploadVideo reject
with the non-retryable APIProviderQuotaExhaustedError, locking the
classifyKimiQuotaError argument at the upload catch sites.
OpenAI-compatible endpoints disagree on the wire field for reasoning
content: `reasoning_content` (DeepSeek/Moonshot convention, older vLLM)
vs `reasoning` (OpenAI GPT-OSS guidance, current vLLM — which also only
accepts `reasoning` on the request side, vllm-project/vllm#38488).
Reading only `reasoning_content` silently dropped thinking, and the lost
thinking then never made it back into later requests.
Add per-endpoint dialect detection: inbound responses are scanned in
priority order (reasoning_content, reasoning_details, reasoning; first
string value wins), the carrying key is remembered, and outbound
messages echo thinking under the same key (default reasoning_content).
An explicit `reasoningKey` / `reasoning_key` config still pins the
dialect.
- kosong: shared reasoning-key module; wire the kimi and openai-legacy
providers; the dialect cell is shared across per-step provider clones.
- agent-core-v2: same mechanism in the vendored openai-legacy base; the
kimi trait no longer pins reasoning_content (the default already is),
keeping Moonshot wire behavior byte-identical while adapting to vLLM.
- agent-core: memoize the base provider behind ConfigState.provider so
the detected dialect survives across turns instead of being rebuilt
per access.
* fix: gate always-thinking inference to OpenAI wires, plus review follow-ups
- catalog: strip the inferred alwaysThinking marker on non-OpenAI wires so
Claude/Gemini keep their native off; verified against live models.dev
- v1 provider-manager: honor per-alias baseUrl on kimi/google-genai/vertexai
- thinking: rewrite the PHASE-6 contract comment, drop three dead
cannot-disable warning branches, normalize requested effort in v1 to
match v2
- tests: input-cap compaction preference in both engines, kap-server WS
status cap, v2 legacy status input cap
- docs + changeset: new model fields, catalog-refresh behavior, kap-server
* fix: strip always-thinking only where the wire encodes a true off
The previous gate kept the marker only on the OpenAI wires, which wrongly
stripped it from Gemini 3 on the Google wires: its floor is
thinkingLevel MINIMAL with suppressed thoughts — still reasoning — so an
Off option there would be a lie. The criterion is now the wire's encoding,
not its family: strip only on anthropic and kimi, the two wires with a
protocol-level `thinking: {type: 'disabled'}` that the catalog's effort
list can never show. Verified against live models.dev data: google now
marks exactly the gemini-3 family (10), anthropic and moonshotai stay 0.
* docs(agent-core-v2): move the strict-validation contract into the thinking.ts file header
The scoped guide keeps comments in the top-of-file block only; the
function JSDoc shrinks to a short what-it-answers note matching its
neighbors. No behavior change.
* fix: five correctness follow-ups to the catalog metadata work
- Normalize a configured effort value (case/whitespace) before thinking
resolution on both engines, so "OFF" is read as off instead of being
sent upstream as an invalid effort.
- Clamp a declared input cap to the effective context window in the
effective model resolution, so an override lowering max_context_size
cannot leave a stale larger cap behind.
- Report context usage against the same effective cap
(max_input_tokens ?? max_context_tokens) in the SDK getStatus and the
v2 legacy status projections, matching the session-event and service
surfaces.
- Preserve concretely declared per-model endpoints when the override
npm is unrecognized, via the same OpenAI-compatible fallback used for
top-level entries.
- Attribute resolved.maxInputSize and resolved.capabilities
.max_input_tokens in the model inspector to their config, override,
or clamp provenance.
* fix: honor observed context caps and publish the effective cap in v2 status
- The overflow-learned provider window is now written into both
max_context_tokens and max_input_tokens of the effective compaction
context, so the strategy cannot bypass it by re-selecting the raw
catalog input cap during overflow recovery.
- agent.status.updated publishes max_input_tokens ?? max_context_tokens,
matching the other v1/v2 status surfaces.
* fix: never mutate config on clamp, cap usage ratio, and refuse proprietary override SDKs
- effectiveModelAlias / effectiveModelConfig now build a copy before
clamping maxInputSize to the effective window instead of rewriting
the caller's config record in place.
- context_usage is clamped to 1 in the v2 legacy status, the v1
session service, and the SDK getStatus; the default-model status
fallback also resolves the input cap.
- Overrides naming known proprietary SDKs (Bedrock, Cohere) are
refused before the OpenAI-compatible fallback, matching top-level
import behavior.
- The inspector attributes a clamped maxInputSize to the clamp even
when the raw value came from models.*.overrides.
* fix: normalize forced efforts, prefer input cap in WS status, keep raw event ratio
- Whitespace-only configured efforts now read as absent, and the
KIMI_MODEL_THINKING_EFFORT override is lowercased on both engines.
- kap-server's WS legacy status publishes max_input_tokens ??
max_context_tokens like the other status surfaces.
- SDK getStatus keeps the ratio unclamped on purpose (>100% is the
documented overflow signal on that path); schema-bounded REST status
stays clamped to 1.
- Pin the provider-observed window beating a declared input cap with a
v1 full-compaction regression test.
* fix: attribute resolved.maxInputSize provenance and drop test-body comments in v2
* fix: stop advertising Claude thinking efforts for non-Claude models
Models served over the Anthropic protocol whose names carry no Claude
marker (e.g. a catalog-imported Kimi K3) no longer inherit the latest
Opus effort list, so the model selector stops offering levels the model
does not accept. The models.dev catalog import now also parses
reasoning_options and records the declared effort levels on the model
alias, so K3 offers its real levels (low / high / max).
* fix: consume deprecated, override, and input-limit metadata from the models.dev catalog
- Models declared status=deprecated in the catalog are no longer
offered for import.
- Per-model provider overrides on gateway providers (an npm package
targeting an Anthropic SDK plus a usable endpoint) now land as alias
protocol and base_url, so those models are served over the right
protocol and endpoint; overrides without a usable URL are skipped.
- A declared limit.input now sizes the context budget instead of the
larger total context window (e.g. gpt-5: 272k instead of 400k).
The model alias schema gains an optional base_url field (not accepted
in overrides) that Anthropic wire resolution prefers over the
provider-level base URL.
* fix: honor thinking-disable semantics and the OpenAI-compatible fallback in catalog imports
- reasoning_options 'none' is the model's off encoding: off_effort flows
from the catalog through the model alias to the OpenAI wire providers,
so turning thinking off sends 'none' instead of omitting the effort
field; models with effort levels but no way to disable thinking are
imported as always_thinking and no longer offer an Off option.
- Bare Claude family aliases (e.g. sonnet-latest) recover the inferred
Anthropic effort profile; v2 comment conventions restored.
- Providers whose SDK the catalog does not type now fall back to the
OpenAI-compatible wire (with a visible "guessed" note) instead of
being refused; imports lacking a usable endpoint ask for one
(--base-url on the CLI, a prompt in the TUI). Proprietary SDKs
(Amazon Bedrock), unrecognized explicit types, and env-placeholder
URLs are refused with a clear reason.
* fix: align catalog imports with the reference models.dev consumer
- A JSON null tier in declared effort values is now read as the 'none'
off-encoding (previously such models were wrongly imported as
always-thinking with no way to turn reasoning off).
- Alpha-status models are filtered out alongside deprecated ones.
- Models whose per-model provider override targets a wire that cannot be
expressed per-model (e.g. Claude on google-vertex, whose wire here is
Gemini-mode Vertex, or gpt entries on an Anthropic provider) are
skipped instead of being imported under the silently wrong protocol.
- interleaved: true no longer pins reasoning_content: the provider's
default three-field scan is wider and the pinned key only narrowed
reasoning parsing for gateways answering with another field name.
* fix: require endpoints for Anthropic-compatible catalog imports and honor --base-url
- catalogProviderNeedsBaseUrl now covers the Anthropic wire: a
non-official Anthropic-compatible vendor without a concrete catalog
endpoint (e.g. google-vertex-anthropic) must supply --base-url / the
TUI prompt instead of silently falling back to the default Anthropic
endpoint.
- --base-url now takes precedence over the catalog-declared endpoint,
and an empty --base-url is rejected instead of persisting a blank
endpoint.
* fix: enforce always-on thinking on every wire and refuse Cohere at import
A model that declares always_thinking (e.g. a catalog-imported gpt-5)
no longer resolves to a dishonest off state via thinking.enabled=false
or an SDK/ACP off request: resolution clamps to the model's default
effort on every wire instead of letting upstream keep reasoning while
the UI reports Off. The Anthropic warn-and-send path for unlisted
effort levels is unchanged. Cohere's proprietary SDK joins Amazon
Bedrock on the import-refusal list instead of being guessed as
OpenAI-compatible.
* fix: harden catalog import edge cases
- An explicit but unrecognized catalog type is now refused before
npm/id inference, so a future catalog protocol is never silently
miswired through the OpenAI fallback.
- User-supplied --base-url values for Anthropic-wire providers get the
same trailing-/v1 normalization as catalog endpoints, avoiding
/v1/v1/messages requests.
- The TUI import prompt rejects env-placeholder base URLs like the CLI
does.
* fix: await the floating assertion promise in the catalog add CLI test
* refactor: unify catalog import resolution into a single decision function
Wire-type inference, the OpenAI-compatible fallback, proprietary-SDK
refusal, endpoint adaptation, and the base-URL requirement are now
produced together by resolveCatalogImport, one pure resolver consumed
by both the CLI and the TUI — replacing the cooperating predicates
(inferWireType, isGuessedWireType, catalogProviderNeedsBaseUrl) whose
permutations kept producing edge cases. No behavior change.
* fix: close configured-off clamp hole, keep inferWireType compat, carry same-wire override endpoints
- A configured thinking.effort = "off" no longer bypasses the
always-on clamp: it is treated as absent and the model default
applies, mirrored on both engines.
- The previously public inferWireType stays as a deprecated
compatibility wrapper over resolveCatalogImport so existing SDK
consumers do not break on a patch release.
- Catalog model overrides that stay on the provider's wire but declare
their own endpoint now persist it on the alias (and the v1 OpenAI
wire branches honor alias-level base URLs like the Anthropic branch).
* fix: split total window from input cap and close override/endpoint gaps
- max_context_tokens once again means the total context window (used by
completion budgeting); a model's declared input limit is tracked as
max_input_tokens, which compaction, context-splice and usage-ratio
checks prefer — fixing the over-clamping introduced when the input
cap was stored as the context budget.
- A catalog endpoint declared only as an env placeholder now always
produces needs-base-url (official SDK included), so credentials are
never sent to the public vendor host by default.
- api-only per-model overrides are honored as same-wire endpoint
changes; overrides targeting another known but inexpressible wire
(e.g. google-genai on an OpenAI gateway) are skipped; same-wire
models whose declared endpoint is an unusable placeholder are
skipped instead of silently rerouted.
* style: drop a function-level comment from the v2 thinking resolver
* chore: consolidate the PR's changesets into two user-facing entries
* refactor(v2): land kosong contract layer (L0 wire contract)
* refactor(v2): land kosong protocol layer (L1 traits and base registry)
* refactor(v2): land kosong provider layer (bases, trait composers, kimi definition)
* refactor(v2): land kosong model and catalog layers
* refactor(v2): migrate engine callers to kosong, drop old llmProtocol layer
* test(v2): migrate agent-core-v2 tests and harness to kosong
* refactor: sync peripheral packages to kosong architecture
* refactor(v2): replace provider dialects with per-protocol definitions
* refactor(v2): merge the vertexai protocol into google-genai via providerOptions
* refactor(v2): remove vendor-name gates outside the kosong layer
* feat(v2): add model resolution inspection and connectivity ping
* refactor(v2): remove the unused platform config layer
* test(klient): pin invalid-input behavior across providers in e2e
* refactor(agent-core-v2): merge kimi traits, split bases by protocol
- merge the seven kimi trait modules into kimi.contrib.ts as two trait
objects: kimiOpenAITrait (all native-transport hooks) and
kimiAnthropicTrait (thinking only)
- move bases/* implementations into per-protocol directories (openai/,
anthropic/, google-genai/) and rename openai.contrib.ts to
openai-legacy.contrib.ts
- add per-directory index.ts registration barrels (import = registration)
and exempt them in check-domain-layers.mjs alongside *.contrib.ts
* refactor(agent-core-v2): reorganize model and request-layer types
- consolidate shared model types (ModelOverrides, CompletionBudgetConfig/Params,
ResolvedModelAuthMaterial, ThinkingDefaults/ModelThinkingMetadata) into
kosong/model/model.types.ts and drop modelOverrides.ts
- rename L2 request types to the ModelRequest* prefix: LLMEvent ->
ModelRequestEvent, LLMRequestInput -> ModelRequestInput,
LLMCallParams -> ModelRequestParams
- extract ModelRequestTiming to replace three duplicated copies of the
stream-timing shape
- rename L3 llmRequester types with the Agent prefix to match
IAgentLLMRequesterService (AgentLLMRequestOverrides/Finish/Task/Source/
PartHandler/LogFields)
- delete the unused LLMRequestParams type
* refactor(agent-core-v2): fold kosong/catalog into kosong/model
- merge IModelCatalogService's enumeration surface (listModels /
listProviders / getProvider / setDefaultModel and the wire shapes) into
IModelCatalog; delete kosong/catalog/modelCatalog.ts
- move the remote refresh path to the new IProviderDiscoveryService
(discovery.ts + discoveryService.ts, renamed from
catalog/modelCatalogService.ts)
- relocate configSection / errors to discoveryConfigSection.ts / errors.ts;
DEFAULT_MODEL_SECTION now lives in kosong/model/model.ts
- drop the L3 catalog layer from check-domain-layers.mjs
- kap-server routes / refresh scheduler / channelRegistry follow the split;
klient renames the contract modelCatalogService to modelResolver and adds
providerDiscovery; kimi-inspect reads via IModelCatalog
- modelRequesterImpl: drop the streamedAnyPart backfill, onMessagePart
already delivers every part
- tests: remove the app/modelCatalog and kosong/catalog suites, add the
kosong/model catalog and discovery suites
* feat(klient): trim trailing undefined args and add boundary smoke probes
- add trimTrailingUndefined helper so optional trailing args no longer
cross the wire as null in http/ipc transports, which defeated
server-side default parameters
- add model-requester-boundary smoke probe for ChatProvider error
wrapping behavior against real config and a local stub
- add kimi-select-tools smoke probe verifying the kimi-only wire
encoding of dynamic tool declarations
- extend smoke.ts with a models set/get/delete round-trip, catalog
list assertions, and update AGENTS.md with the new scripts
* fix(agent-core-v2): declare openai chat hooks as function properties
Method-shorthand members on OpenAIChatCompletionsHooks tripped
typescript-eslint(unbound-method) at every extraction site
(`const hook = this._hooks?.convertMessage` and friends), failing the
repo-wide lint job. Every implementation is a plain closure composed by
openaiHooks.ts, so declare the members as function-typed properties,
which matches the actual semantics and clears the four errors.
* fix: repair stale references surfaced by the origin/main rebase
- agent-core-v2: point vacuousContent's ContentPart import at kosong/contract
- kap-server: rewrite the transcript test seed as IModelCatalog (IModelResolver is gone)
- klient: inline onceEvent/waitFor after the http transport helpers were dropped
- kimi-inspect: remove useLiveEvent from ModelCatalogView; catalog polls on a slow interval
* chore: downgrade the kosong architecture changeset to patch
* fix(agent-core): drop vacuous assistant messages that permanently wedge sessions
A provider-filtered response can seal an assistant message holding only an
empty thinking part into the recorded history. The projection's empty-message
guard only counted parts, so it survived, and was serialized as an assistant
message with no content and no tool calls — which the provider rejects with
"the message at position N with role 'assistant' must not be empty" on
every resend, permanently wedging the session.
The projection now drops any message whose parts all serialize to nothing
(empty/whitespace text, or empty unsigned thinking). Content-bearing
messages keep every part verbatim, including empty thinking blocks that
preserved-thinking providers require back. Drops surface through a new
vacuous_message_dropped projection anomaly with log/telemetry counters, and
the structural-error recovery now recognizes this provider rejection so the
strict resend self-heals any residual path. Mirrored in agent-core-v2.
* docs(agent-core-v2): move vacuous-message rationale into the file header
The v2 comment convention keeps comments solely in the top-of-file block.
Move the vacuous-message drop rationale there and drop the inline/JSDoc
comments added beside statements in the projector service, the llmProtocol
error patterns, and the projector tests. No behavior change.
* fix(agent-core-v2): drop output-free steps at fold settle time
The loop-event fold already dropped a settled assistant message when it was
structurally empty (no content, no tool calls), but a step that recorded
only an empty thinking part — e.g. a provider-filtered response carrying an
empty reasoning field — survived into history, relying on the projection to
keep it off the wire. Widen the settle condition to drop the step whenever
nothing sendable was recorded (no tool calls; every content part vacuous),
using a predicate now shared with the context projector. This heals stored
v2 histories at restore time instead of only at request time.
* fix(agent-core): drop duplicate-stripped assistants left with only vacuous content
The strict resend's dedupe pass could re-create the exact empty-assistant
shape the projection now guards against: when every tool call a message
carried was removed as a later duplicate, a remainder holding only an empty
thinking part was kept (its content was non-empty), serialized as an
assistant message with no content and no tool calls, and rejected again
with "the message at position N with role 'assistant' must not be empty"
— the same failure the strict resend was recovering from.
The keep condition now requires sendable content, so such a message is
dropped wholesale with a vacuous_message_dropped anomaly alongside the
duplicate-tool-call one. Mirrored in agent-core-v2.
* fix(agent-core-v2): mirror output-free step drops in the transcript reducer
The live loop-event fold drops a settled assistant step when nothing
sendable was recorded (no tool calls; every content part vacuous), but the
cold transcript reducer still pushed an assistant on every step.begin and
never dropped it. Cold readers (snapshot / messages) therefore kept
showing output-free phantom assistants — including one per failed retry
attempt, which predates the vacuous-step case — and foldedLength overcounted
the live folded history, which can misplace the unflushed-tail splice in
the messages view while a turn is in flight.
The reducer now settles steps the same way the fold does: at a step's end
or at the next begin, an assistant with no tool calls and only vacuous
content is removed and foldedLength is decremented. Steps carrying any
sendable output — real text, real thinking, signed thinking, or tool calls
— are kept verbatim.
* fix(agent-core-v2): update vacuous-content predicate documentation for clarity
* docs(changeset): simplify the vacuous-assistant fix changelog entry
An explicit withThinking('off') collapsed to the same internal state as
"never configured" on chat-completions providers, so the history-based
auto reasoning_effort injection (#1616) silently switched reasoning back
on and could leak the field to models that reject it. Store the requested
effort verbatim and derive the wire encoding per request, suppress the
auto-enable for an explicit 'off', and report the accurate current effort
('on'/'off') instead of recording 'off' for both.
Remove the wire-level fallback that substituted a space for empty
thinking content when replaying assistant history to Anthropic-compatible
and Kimi endpoints with thinking keep=all. The strict endpoints that
motivated the placeholder no longer reject empty thinking, so the
placeholder only distorted replayed history.
* feat: attach KFC trace_id to telemetry events
Capture the x-trace-id response header in both kimi providers (kosong
and agent-core-v2 llmProtocol) via the openai SDK withResponse(), and
thread it through the request result chain into telemetry so events can
be joined with server-side request logs by trace id instead of
session_id + time window.
- kosong / llmProtocol: StreamedMessage/GenerateResult.traceId,
GenerateOptions.onTraceId early capture (available mid-stream),
APIStatusError.traceId on the error path
- v1 (agent-core): per-turn traceIdByTurn on TurnFlow; attach trace_id
to turn_ended/turn_interrupted/api_error/cancel/tool_call(+dedup,
repeat)/permission_approval_result/question_*/compaction_*
- v2 (agent-core-v2): AgentTelemetryContext carries turn_id/trace_id
updated by llmRequester; registry gains trace_id on 13 events plus
turn_id on turn_* and turn_id/step_no on api_error
* fix: attribute trace_id to the failed request on error paths
Review follow-ups for trace_id attribution on failure paths:
- v1: chatWithRetry feeds a failed attempt's APIStatusError trace id into
the turn's traceIdByTurn before the loop dispatches turn.interrupted,
so turn_ended/turn_interrupted on error turns attribute to the failed
request instead of the previous successful step.
- v2: llmRequester keeps the onTraceId-captured trace in per-request
state and trackApiError prefers it over error extraction, so failures
after response headers arrived (empty response, mid-stream decode
errors) no longer clear the ambient trace_id.
- v2: compaction_failed falls back to the ambient trace_id once a
summarizer request actually hit the wire, covering mid-stream failures
whose error carries no trace.
- kosong: parseTraceId filters empty x-trace-id headers and both kimi
providers read the header through it.
- v2 test harness forwards onTraceId to GenerateFn so tests can simulate
the early header capture.
* fix: attribute v1 api_error to the in-flight request on post-headers failures
- v1: a failure after response headers arrived (mid-stream decode error,
empty response) carries no trace on the error itself, so api_error lost
the trace the client had already captured via onTraceId. Add a per-step
in-flight capture (written by onTraceId, cleared at step begin/end) and
fall back to it when the error carries no trace, aligning with v2's
per-request requestTraceId. Failures before any response headers
(network errors, local aborts) still report no trace rather than
leaking a previous request's.
- v2: document that ambient trace_id distribution assumes serialized LLM
requests per agent, naming the two supported paths that break it
(after-step compaction below the block ratio; inject-path turns during
a manual compaction).
* fix: isolate telemetry trace attribution
* fix: isolate telemetry trace by request
* fix: declare @moonshot-ai/protocol dependency of agent-core-v2
The trace-by-request refactor imports types from @moonshot-ai/protocol
in toolContract and toolExecutorService, but the package was not
declared, failing clean CI installs (TS2307). Also drop three type
imports that are unused after the merge with main.
* fix(agent-core-v2): make Gemini tool call ids unique across turns
* fix(kosong): recover tool names from entropy-suffixed Gemini call ids
The orphan tool-message fallback in toolCallIdToName stripped only one
trailing "_<suffix>" segment, so with the new
"{tool_name}_{upstream_id}_{entropy}" id shape it returned
"AgentSwarm_0" instead of "AgentSwarm" once the preceding assistant
call had been compacted away. Strip the fixed 8-hex entropy suffix
first, then the upstream id segment, in both the kosong and
agent-core-v2 providers. Also drop the inline implementation comment
that agent-core-v2 forbids outside the file header.
---------
Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
* fix: preserve provider thinking effort values
* fix: resolve Kimi thinking effort fallbacks
* fix: use resolved Kimi effort in v2 requests
* fix: align Kimi effort resolution paths
* fix: synchronize forced Kimi effort state
* fix: synchronize forced Kimi effort in v2 state
* fix: tolerate unresolved models in v2 status
* refactor(kosong): rename select_tools capability to dynamically_loaded_tools
Rename the `ModelCapability` bit from `select_tools` to `dynamically_loaded_tools` everywhere it is declared, detected, catalogued, and forwarded: kosong `ModelCapability`/catalog, agent-core capability resolution and the `toolSelectEnabled` gate, the SDK catalog-to-alias mapping, and the built-in catalog pruner's keep list.
The old `select_tools` spelling is removed outright rather than kept as an alias — no catalogued model or shipped configuration used the capability, so there is nothing to migrate. Client-side vocabulary (the `select_tools` builtin tool and the `tool-select` experimental flag) is intentionally untouched.
* chore: shorten changeset description
---------
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
* fix: refuse unsupported image formats instead of poisoning sessions
Images in formats providers reject (AVIF, HEIC, BMP, TIFF, ICO) used to
pass through to the API, and the resulting HTTP 400 repeated on every
later turn because the image_url stayed in the session history.
Add a single format policy (accepted set: PNG/JPEG/GIF/WebP) enforced at
every ingestion point: ReadMediaFile refuses with a per-OS conversion
command; MCP tool results, REST uploads, and ACP prompts replace the
image with a text notice; and turn.prompt/steer gates as the last-funnel
backstop so the SDK/RPC path cannot poison a session either. Accepted
MIME aliases (image/jpg, case/whitespace) are forwarded in canonical
form, and data URLs carrying MIME parameters can no longer slip past the
gate. Remote image URLs pass through (no bytes to inspect).
* fix: canonicalize accepted data URLs with MIME parameters
The format gate compared only the MIME token when deciding whether to
rebuild a data URL, so an accepted image carrying MIME parameters
(`data:image/jpeg;charset=utf-8;base64,...`) was forwarded with its
original header. The Anthropic provider splits the data URL and
exact-matches the full header against its whitelist, so the part still
poisoned the session. Rebuild to the byte-exact canonical URL whenever
the original differs, covering aliases, case/whitespace, and parameters
with one comparison.
Addresses review feedback on PR #1536.
* fix: parse data URLs case-insensitively in the image format gate
An uppercase `;BASE64,` marker is legal (RFC 2045 encoding names are
case-insensitive), but the parser required a lowercase match and
returned null, so the gate treated the URL as remote and forwarded it:
an unsupported image could still land in the session history, and the
Anthropic provider's lowercase-only split then threw on every turn.
Match the scheme and marker case-insensitively; the canonical rebuild
emits the lowercase form.
Addresses review feedback on PR #1536.
* fix: harden image format handling against mislabeled and legacy images
Two more ways an unsupported image could reach the provider are closed:
- Bytes, not labels, decide the format. A data-URL image whose declared
MIME disagrees with its magic bytes (e.g. AVIF bytes an image search
tool labels image/png) is now gated on the sniffed format at every entry
point (MCP results, ACP, SDK/RPC prompt, REST inline and file uploads),
so a mislabel cannot slip past the gate.
- A poisoned image already in the session history no longer kills the
session: a server image-format 400 (or kosong's client-side image
rejection) now retries once with every media part replaced by a text
marker, mirroring the 413 media-degraded recovery. The recovery also
fires during compaction, and the transient-retry fallback no longer
burns the retry budget on image-format errors before the dedicated
recovery can run.
* fix: reject remote image URLs ending in an unsupported extension
Remote image URLs (MCP resource_link, REST `kind: 'url'`) carry no bytes
to sniff, so a link ending in `.avif` (or `.heic`, `.bmp`, `.tiff`,
`.ico`) would pass through and be fetched server-side — and rejected.
Reject such URLs by their path extension instead (query/fragment
ignored, case-insensitive); extensionless or accepted-extension URLs
still pass through to the provider and the 400 recovery.
* fix: tighten image format handling for parameterized MIMEs and recovery scope
Address two review findings on PR #1536:
- A declared media type with parameters (e.g. image/jpeg; charset=utf-8)
is no longer misread as unsupported: normalizeImageMime now strips
parameters, matching the data-URL parser, so an accepted image with
parameters is forwarded instead of dropped.
- The image-format recovery predicate is narrowed to specific
format/data rejection phrases, so a 400 about image count, size, or
image-input support no longer triggers a media-stripped resend that
would let the model answer blind to the user's images.
* fix
* fix: scope image format recovery to images and flag remote SVG URLs
- The media_type/mime_type recovery match now requires the message to
mention an image, so a video/audio media_type rejection surfaces
instead of triggering a blind media-stripped resend.
- unsupportedImageMimeFromUrl flags .svg URLs as image/svg+xml without
touching the shared suffix map (SVG stays text for the file tools),
so remote SVG images get the intended notice instead of a provider
rejection.
Addresses review feedback on PR #1536.
* fix: reject remote MCP images by their declared MIME type
An MCP resource_link with an extensionless or signed URL gives the
extension gate nothing to work with, and convertMCPContentBlock was
discarding the declared mimeType — an honestly-declared AVIF/HEIC link
from an image search tool still became an image_url and poisoned the
session. Reject on the declared MIME when the server provides one:
unsupported declarations become a text notice that keeps the URL so the
model can fetch and convert it; accepted declarations pass through as
before.
Addresses review feedback on PR #1536.
* fix: keep image format recovery image-specific and preserve dropped URLs in notices
- Drop the bare `media` alternative from the image-format recovery
patterns so audio/video media rejections ("unsupported media type",
"invalid media type") can never be misclassified as image errors and
blindly media-stripped; every pattern now mentions "image" literally.
- Remote image URLs rejected by their extension now keep the URL in the
replacement notice (gateImageFormatParts and the REST url path), so the
model can still fetch and convert the image — matching the declared-MIME
resource_link path.
Addresses review feedback on PR #1536.
* fix: drop malformed data URLs at ingestion instead of letting them poison the session
A `data:` URL that fails to parse (missing `;base64,` separator, empty
MIME, …) was treated like a remote URL and passed through the format
gate; the provider then rejects it on every turn, and the read-side
media-stripped recovery keeps paying that round-trip until compaction.
Detect unparseable `data:` URLs in gateImageFormatParts and replace them
with a (truncated) notice at ingestion, covering the MCP/ACP/SDK/turn
paths that share the gate.
Addresses review feedback on PR #1536.
* feat(retry): harden LLM API fault tolerance against 429/overload
- retry more transient errors: 408/409/429/5xx/529, an embedded upstream
status_code=429 in OpenAI Responses stream errors, and unclassified
provider errors as a last-resort fallback
- honor server Retry-After (parsed into APIStatusError.retryAfterMs by the
OpenAI and Anthropic providers); chatWithRetry prefers it over its backoff
- align app-level backoff with claude-code (500ms base, 32s cap, factor 2,
up to 25% jitter) so high-attempt configs ride out multi-minute overload
- emit a turn.step.retrying meta line in -p --output-format stream-json
* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type
* feat(agent-core): lower default image downscale cap to 2000px and make it configurable
* feat(agent-core): strip media to text markers and retry when the compaction request is too large
* feat(agent-core): cap model-initiated image reads with a configurable byte budget
* feat(agent-core): resend with degraded media when the provider rejects the request body as too large
* test(agent-core): add explicit timeouts to encode-heavy image budget tests
* feat: add WebP decoding support with wasm integration
- Introduced a new WebP decoding module using @jsquash/webp's wasm decoder.
- Implemented functions to decode WebP images and check for animated WebP formats.
- Updated image compression tests to include scenarios for WebP handling, including encoding and decoding.
- Enhanced error handling for API request size limits to accommodate various error messages.
- Updated pnpm lockfile to include new dependencies for WebP encoding and decoding.
* chore(changeset): consolidate this PR's entries into one
* fix(nix): update pnpmDeps hash for merged lockfile
* feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
* feat(kosong): honor explicit anthropic max output override
Add claude-opus-4-8 output ceiling and treat explicit max_output_size/KIMI_MODEL_MAX_OUTPUT_SIZE as the final Anthropic max_tokens value. Sync configuration and environment variable docs.
* feat(kosong): drop claude-opus-4-8 ceiling
Revert the newly added claude-opus-4-8 default output ceiling while keeping explicit max_output_size overrides for Anthropic.
* fix(kosong): fall back to nearest lower catalogued minor for Claude max tokens
An uncatalogued Claude minor version (e.g. claude-opus-4-8) previously
dropped straight to the family/major baseline ceiling, so Opus 4.8
resolved to 32k max output tokens instead of the 128k it supports.
Walk the minor version down to the nearest catalogued entry before
using the family baseline, so a newer minor inherits its predecessor's
documented ceiling.
* fix(kosong): catalogue Opus 4.8's documented 128k output ceiling
Known models should resolve from explicit table entries; the
nearest-lower-minor fallback now only covers minors that are not yet
catalogued. Fable 5 already has an explicit entry.
* feat(agent-core): record llm request trace in wire.jsonl
Add three observability record types so every request sent to the model
can be reconstructed from the wire log at the logical-request level:
- llm.tools_snapshot: content-addressed snapshot of the top-level tools
table as sent (post deferred-strip), written once per unique table
- llm.request: one record per outbound request (retries, strict resends,
and compaction rounds included) carrying the effective request params
and hash links to the system prompt and tools snapshot
- mcp.tools_discovered: the server's verbatim tools/list result plus the
agent's gating (allow-list, collisions), deduplicated by content hash
Observability records never feed state rebuild; replay only restores the
write-dedup cursors. The records/types.ts contract now documents the two
record classes explicitly (persisted is not the same as replayed).
Recording happens at the single Agent.generate choke point. The
LLMRequestLogFields side channel gains kind/projection/maxTokens/
droppedCount, chatWithRetry preserves caller-set fields, and compaction
tags its requests. The vis wire view renders the new record kinds.
* fix(agent-core): record the provider-clamped completion cap in the request trace
The llm.request trace recorded the client-requested budget cap, but
chat-completions providers tighten the actual wire value inside
withMaxCompletionTokens (remaining-context sizing, transport ceilings,
model-default resolution) — with the default budget the clamp is active
on nearly every non-empty-context request, so the recorded value did not
match what was sent.
Providers now expose the effective cap they computed as a readonly
maxCompletionTokens field on the clone, and the recorder reads it from
the effective provider at the Agent.generate choke point. This replaces
the side-channel recomputation, which is removed along with the
appliedCompletionBudgetCap helper.
* fix(agent-core): park pre-replay MCP discovery records and hash the collision outcome
Two wire-hygiene fixes for the mcp.tools_discovered trace:
Parking: the real Session ordering connects MCP servers concurrently with
agent construction, so ToolManager can observe a connected server before
agent.resume() has replayed the wire. Recording at that point bypassed
the restored dedup cursor (duplicating a 1-50KB record on every resume)
and appended a stray metadata record ahead of replay. AgentRecords now
exposes a one-shot opened latch — set when replay completes (after the
migration rewrite flushes) or when the first live record is logged — and
ToolManager parks discoveries until then, re-running the dedup check at
drain time. A frozen range-limited replay never opens; those agents are
transient previews.
Collision hashing: the dedup hash now covers the collision outcome, not
just the raw list and allow-list. Collisions depend on which other
servers hold a sanitized qualified name at registration time, so a
server can re-register with identical tools but a flipped outcome; that
gating change must produce a new record instead of being suppressed.
* fix(agent-core): skip the request trace for pre-flight-aborted calls
Mirror kosong generate()'s pre-flight abort check at the Agent.generate
choke point: a call whose signal is already aborted never reaches the
wire (generate throws before dispatching), so it must not leave an
llm.request/llm.tools_snapshot trace or a diagnostic log line claiming a
request was sent. Recording stays before dispatch for every call that
passes the gate, preserving the crash-safety of the trace.
* chore(agent-core): remove a leftover adaptive-thinking override hook
The adaptiveThinkingOverride option was a temporary local hook explicitly
marked for removal before commit. Nothing passes it, so resolution falls
back to the alias-level adaptiveThinking value in all cases; drop the
option and the dead indirection.
* fix(kosong): derive the exposed completion cap from generation kwargs
maxCompletionTokens was a field stored only by withMaxCompletionTokens,
so caps that reach the wire through other paths were invisible to the
request trace: with completion budgeting disabled via env, Anthropic
still sends the constructor-resolved max_tokens (required by the
Messages API), and constructor-level kwargs like OpenAILegacyOptions
maxTokens were likewise unreported.
Replace the stored field with a getter derived from each provider's
generation kwargs — the single source the request body reads — covering
constructor defaults, direct withGenerationKwargs configuration, and
budget application in one place. Kimi mirrors its request-time legacy
max_tokens alias normalization; openai-legacy reuses the same
normalizeGenerationKwargs the request path uses.
* feat(agent-core): add thinkingKeep passthrough for Kimi providers and update tests
* feat(agent-core): enable Preserved Thinking by default on the Anthropic provider
Default thinking.keep to "all" for the Anthropic provider (Claude and Kimi in Anthropic-compatible mode) while Thinking is on, via a context_management clear_thinking_20251015 edit, mirroring the Kimi default. Reuses [thinking] keep and KIMI_MODEL_THINKING_KEEP (env > config > default "all"); off-values disable it.
* feat(kosong): route Anthropic Preserved Thinking through the beta Messages API
Force the beta endpoint (client.beta.messages.create) when thinking.keep is enabled, since clear_thinking_20251015 is only honored there. Also prepend clear_thinking to any existing context-management edits (for example clear_tool_uses) instead of replacing them, keeping it first as Anthropic requires when combining edits.
* docs: clarify Anthropic beta endpoint and compaction keep behavior
Note in code comments and bilingual docs that enabling Anthropic Preserved Thinking routes requests to the beta Messages API (client.beta.messages.create), with keep=off as the escape hatch back to the standard endpoint. Correct the resolveThinkingKeep comment to reflect that compaction shares ConfigState.provider and intentionally carries the same keep.
* test(kosong): cover Anthropic beta endpoint (streaming and forced betaApi)
Add a streaming beta-endpoint capture and a test that withThinkingKeep forces the beta endpoint even when constructed with betaApi: false, pinning down the documented behavior.
* feat(agent-core): progressive tool disclosure via select_tools
Keep MCP tool schemas out of the immutable top-level tools[] and let the
model load them on demand, preserving the provider prompt cache:
- kosong: Message.tools (append-only load primitive, serialized as Kimi
messages[].tools with type:function wrapping and no content),
Tool.deferred (stripped once in generate() so loaded tools stay
executable without re-entering the top level), select_tools capability
bit (UNKNOWN/catalog default false).
- select_tools builtin: load-by-exact-name, three-branch semantics
settled per name (Loaded / Already available / Unknown), schemas read
from the live registry, injection-origin schema messages survive undo.
- ToolsDiffInjector: <tools_added>/<tools_removed> announcements at turn
boundaries and post-compaction, folded from history (undo/compaction/
resume self-heal), appended only when the loadable set changes.
- Loaded-tools ledger = history scan + defer-window pending set (cleared
on /clear); loop re-reads the executable table per step so a selected
tool dispatches on the next step of the same turn; preflight
distinguishes not-loaded from loaded-but-disconnected.
- Cross-cuts: projection strips protocol context for non-select_tools
models (lossless mid-session model switch both ways), compaction
filters it from the summarizer input and rebuilds loaded schemas
keep-all after folding, token estimation counts message.tools, request
logging reflects the post-strip wire tools.
- Three-condition gate: capability.select_tools x capability.tool_use x
tool-select experimental flag (KIMI_CODE_EXPERIMENTAL_TOOL_SELECT).
Any gate closed reproduces the inline request byte-for-byte; all
current models keep the capability off, so behavior is unchanged
until a supporting model is catalogued. The SDK catalog-to-alias
mapping forwards the capability so catalog-driven setups can enable it.
* feat(kosong): skip tool-declaration-only messages in non-Kimi providers
Message-level tool declarations (messages[].tools) are a Kimi wire
feature. The other providers' explicit field construction already keeps
the tools field off the wire, but the content-free leftover message
would be rejected (OpenAI: system message without content) or serialize
as a garbage <system></system> turn (Anthropic/Google system-to-user
wrapping). Skip such messages entirely via a shared predicate; a message
that also carries content only loses the tools field, as before.
Unreachable in kimi-code (the projection gate strips dynamic-tool
context for models without the select_tools capability before any
provider sees it) — defense-in-depth for direct kosong consumers.
* fix(agent-core): survive runtime flag flips and align tool table with post-compaction state
Two fixes from PR review:
- Register select_tools unconditionally and gate only its exposure in
loopTools. The tool-select flag can flip at runtime (config reload
calls setConfigOverrides on the live resolver) without
initializeBuiltinTools re-running; previously the disclosure shape
activated while the tool itself was unregistered, cutting the session
off from MCP entirely until a model/cwd change rebuilt the builtins.
A profile listing the name explicitly still never surfaces it in
inline mode, and execution guards the flip race defensively.
- Resolve the per-step tool table AFTER beforeStep, next to
buildMessages. beforeStep can run full compaction, which trims loaded
schemas and rewrites the ledger; a table captured before it could
still dispatch a tool whose schema the model no longer has. The
executable table and the request messages now always reflect the same
state, so a trimmed tool is rejected with select guidance instead of
executed.
* fix(agent-core): drop unused Tool import in dynamic-tools
* fix(agent-core): baseline compaction guard after post-compaction reinjection
The reinjected reminders (loadable-tools manifest, goal) are re-appended
after every compaction, but the nothing-new-since-compaction baseline was
captured before injectAfterCompaction. With a large manifest the guard
could re-trigger auto-compaction against a floor that cannot shrink.
Raise the baseline to the true post-compaction floor once reinjection
completes; the earlier capture stays as a fallback when reinjection
throws.
---------
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
- send tool declarations, system prompt, and sampling/thinking settings in the camelCase shape the Google SDK forwards so tool calls reach the model
- thread tool-call extras (thought signatures) through the loop tool-call event into context so Gemini 3 can resume a tool turn
- update and add tests for the corrected request shape and signature round-trip
* feat(agent-core): guide the model away from repeating denied or failed tool calls
- system.md: add a diagnose-before-retrying paragraph next to the existing
permission-denial guidance, covering failed tool calls
- permission: when the user rejects an approval on the main agent, tell the
model not to re-attempt the exact same call (sub agents already had an
equivalent hint)
* fix(agent-core): close abandoned tool exchanges and dedupe duplicate tool_use ids
A turn that dies between a recorded tool.call and its paired tool.result
(e.g. a transcript write failure mid-batch) used to leave
pendingToolResultIds open forever: every later message was stranded in
deferredMessages and user input was silently swallowed.
- runOneTurn now defensively closes any dangling tool calls when a turn
ends (completed, cancelled, or failed), synthesizing an error result
that names the cause, with a warn log and a tool_exchange_abandoned
telemetry event
- the projector drops assistant tool calls whose id already appeared
earlier (first occurrence wins): a duplicate id is wire-invalid on
strict providers and not repairable by the strict resend; reported via
the existing projection-repair log and telemetry
- resume-side closePendingToolResults now logs what it closes (warn for
a mid-history gap, info for the routine trailing interruption)
* chore: add changesets for tool exchange fixes
* fix(agent-core): scope duplicate tool_use id dedup to the strict resend
Unconditional dedup regressed providers that emit per-response counter
ids (e.g. call_0 in every step) and accept their own duplicates: later
tool exchanges silently vanished from the projected history, and a
duplicate call's own recorded result was left dangling.
- the dedupe pass is now opt-in via dedupeDuplicateToolCalls and enabled
only in strictMessages, so the normal projection keeps the history the
provider produced
- the pass also drops every tool result after the first for an id, so no
dangling tool message survives; when the kept call has no result of
its own, the surviving one is reattached by the adjacency repair
- kosong now classifies the Anthropic "tool_use ids must be unique" 400
as a recoverable request-structure error so it triggers the strict
resend
* fix(agent-core): recover sessions bricked by orphan tool results
A stray `tool` message with no preceding assistant `tool_calls` permanently
bricked a session on OpenAI-compatible providers: every turn re-sent the same
malformed history and got a 400, and switching model/provider did not help.
Two independent gaps caused this:
- kosong did not recognize the OpenAI / DeepSeek / vLLM / Qwen phrasings of the
tool-exchange structural 400 (`role 'tool' must be a response to a preceding
message with 'tool_calls'` and the mirror `assistant message with 'tool_calls'
must be followed by tool messages`), so the post-400 strict-resend fallback
that drops the orphan never fired.
- The legacy-restore compaction path kept a verbatim tail
`history.slice(compactedCount)`; when the cut landed inside a tool exchange the
tail began with an orphan tool result whose assistant was summarized away. The
normal projection does not repair a leading orphan, so the malformed history
was baked in and re-sent every turn.
Recognize the additional phrasings so the strict resend un-bricks any session,
and trim leading tool results from the legacy-restore tail so the orphan is
never persisted in the first place.
* fix(agent-core): drop orphan tool results at the projection boundary
Rework the legacy-restore half of the fix based on review feedback: mutating
`_history` at restore time desyncs every consumer that models the history from
the wire records — the transcript reducer's fold length would overcount and
make MessageService skip unflushed live-tail messages.
Keep the restored history faithful to the wire records instead, and drop a
`tool` result whose call is nowhere in the history at the projection boundary,
on every request-building projection: the normal wire (`messages`), the
post-400 strict resend (`strictMessages`), and the compaction summarizer. An
orphan is wire-invalid on strict providers and useless to the model either
way, so it never reaches a provider — no longer relying on recognizing the
provider's 400 phrasing to recover. Fragment projections (e.g. token-estimating
a history slice) leave results untouched, since a matching call may
legitimately sit outside the slice.
Moonshot / Kimi (OpenAI-compatible) rejects a history whose tool message
references a tool_call_id with no matching tool_calls entry in the preceding
assistant message as `400 tool_call_id is not found`. The
TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS only covered Anthropic's
tool_use/tool_result phrasing, so isRecoverableRequestStructureError returned
false, the strict-resend fallback in executeLoopStep never fired, and the
session stayed permanently stuck re-sending the same rejected history every
turn (observed in the field after a manual compaction busted the prompt cache
and forced full revalidation of a latently misordered prefix).
Add the tool_call_id-anchored pattern so the whole recovery chain — strict
projection (adjacency repair, orphan-result drop, synthetic results) plus the
one-shot resend — now also covers the default provider. Covered by classifier
unit tests and an e2e resend-and-recover case.
A raw undici `terminated` error — an SSE/HTTP response body cut mid-flight,
common on long streaming responses — fell through convertAnthropicError to a
generic base ChatProviderError, which isRetryableGenerateError treats as fatal,
so it was never retried. Route raw non-SDK errors through the shared
classifyBaseApiError heuristic (already used by the OpenAI path) so a dropped
stream is classified as a retryable APIConnectionError and retried instead of
failing the turn.
* feat: add KIMI_MODEL_THINKING_EFFORT to force a thinking effort
Send thinking effort only when the model declares it in support_efforts, and add the KIMI_MODEL_THINKING_EFFORT environment variable as an escape hatch to force a specific effort regardless of declared support.
* test: align thinking effort expectations with support_efforts gating
Update the kimi adapter e2e and compaction tests that asserted the previous pass-through behavior on models without support_efforts.
* feat: add model alias overrides
Preserve user model overrides across provider catalog refreshes and resolve effective model metadata for runtime, TUI, protocol, and ACP consumers.
* fix: apply model display name overrides
Show overridden model display names in the footer, welcome panel, status output, and model switch confirmations.
* fix: pass through kimi effort when undeclared
Keep support_efforts authoritative when declared, but pass requested Kimi thinking effort through when the model does not declare support_efforts.
* fix: honor model overrides in effort commands
Use effective model metadata for /effort choices and for always_thinking clamping when resolving thinking effort.
* fix(provider): honor base_url for google-genai and vertexai providers
The google-genai and vertexai provider types silently ignored a configured
base_url and always hit generativelanguage.googleapis.com (e.g. a Gemini-
compatible proxy URL + key could not be used). Plumb the endpoint through to
the @google/genai SDK via httpOptions.baseUrl:
- kosong: add baseUrl to GoogleGenAIOptions and inject it into the client's
httpOptions alongside the existing headers (the SDK merges headers and
overrides the base host).
- agent-core: forward provider.baseUrl in the google-genai and vertexai
branches, with GOOGLE_GEMINI_BASE_URL / GOOGLE_VERTEX_BASE_URL env
fallback. vertexai keeps deriving location from an aiplatform host.
- docs: document base_url for both providers, noting the host root only
must be given because the SDK appends the API version itself.
Covered by unit tests asserting the URL reaches the kosong config and the
SDK client's httpOptions.
* fix(provider): use the effective base_url for vertex location detection
The vertexai branch forwarded the endpoint from config `base_url` OR the
GOOGLE_VERTEX_BASE_URL env fallback, but service-account detection
(`hasVertexAIServiceEnv` / `vertexAILocation`) still derived the region from
`provider.baseUrl` only. Supplying the regional endpoint via the env fallback
(with a project but no explicit GOOGLE_CLOUD_LOCATION) therefore left location
undefined and silently downgraded Vertex ADC to API-key Gemini routing.
Resolve the effective base URL once and use it for both forwarding and location
derivation, so the env fallback behaves exactly like `base_url`. Add a
changeset for the kosong + agent-core patch release.