Compare commits

..
12 changed files with 122 additions and 16 deletions

No files matched your search

+2 -1
View File
@@ -2,7 +2,7 @@
## 2026.1.15 (unreleased)
- Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
## 2026.1.14-1
@@ -117,6 +117,7 @@
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: preserve Antigravity Claude signatures and skip Gemini downgrades. (#959) — thanks @rdev.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
+28
View File
@@ -213,6 +213,34 @@ describe("runWithModelFallback", () => {
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
});
it("defaults provider/model when missing (regression #946)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [],
},
},
},
});
const calls: Array<{ provider: string; model: string }> = [];
const result = await runWithModelFallback({
cfg,
provider: undefined as unknown as string,
model: undefined as unknown as string,
run: async (provider, model) => {
calls.push({ provider, model });
return "ok";
},
});
expect(result.result).toBe("ok");
expect(calls).toEqual([{ provider: "openai", model: "gpt-4.1-mini" }]);
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi
+7 -5
View File
@@ -119,8 +119,6 @@ function resolveFallbackCandidates(params: {
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
@@ -128,11 +126,15 @@ function resolveFallbackCandidates(params: {
defaultModel: DEFAULT_MODEL,
})
: null;
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
const defaultModel = primary?.model ?? DEFAULT_MODEL;
const provider = String(params.provider ?? "").trim() || defaultProvider;
const model = String(params.model ?? "").trim() || defaultModel;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER,
defaultProvider,
});
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
const allowlist = buildAllowedModelKeys(params.cfg, defaultProvider);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
@@ -160,7 +162,7 @@ function resolveFallbackCandidates(params: {
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
+6
View File
@@ -8,6 +8,12 @@ export function isGoogleModelApi(api?: string | null): boolean {
);
}
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
if (api !== "google-antigravity") return false;
return modelId?.toLowerCase().includes("claude") ?? false;
}
export { sanitizeGoogleTurnOrdering };
/**
+5 -2
View File
@@ -30,7 +30,7 @@ function isEmptyAssistantErrorMessage(
export async function sanitizeSessionMessagesImages(
messages: AgentMessage[],
label: string,
options?: { sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean },
options?: { sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean; preserveSignatures?: boolean },
): Promise<AgentMessage[]> {
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
@@ -76,7 +76,10 @@ export async function sanitizeSessionMessagesImages(
}
const content = assistantMsg.content;
if (Array.isArray(content)) {
const strippedContent = stripThoughtSignatures(content);
const strippedContent = options?.preserveSignatures
? content // Keep signatures for Antigravity Claude
: stripThoughtSignatures(content); // Strip for Gemini
const filteredContent = strippedContent.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
@@ -59,6 +59,34 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
});
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
modelId: "anthropic/claude-3.5-sonnet",
sessionManager,
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("preserves order when downgrading mixed assistant content", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
+1
View File
@@ -302,6 +302,7 @@ export async function compactEmbeddedPiSession(params: {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
sessionManager,
sessionId: params.sessionId,
});
+7 -2
View File
@@ -13,6 +13,7 @@ import {
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -152,19 +153,23 @@ export function applyGoogleTurnOrderingFix(params: {
export async function sanitizeSessionHistory(params: {
messages: AgentMessage[];
modelApi?: string | null;
modelId?: string;
sessionManager: SessionManager;
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = isGoogleModelApi(params.modelApi)
const downgradedThinking = shouldDowngradeGemini
? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools;
const downgraded = isGoogleModelApi(params.modelApi)
const downgraded = shouldDowngradeGemini
? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking;
@@ -303,6 +303,7 @@ export async function runEmbeddedAttempt(
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
sessionManager,
sessionId: params.sessionId,
});
+8 -2
View File
@@ -4,8 +4,14 @@ export type CommandArgsFormatter = (values: CommandArgValues) => string | undefi
function normalizeArgValue(value: unknown): string | undefined {
if (value == null) return undefined;
const text = typeof value === "string" ? value.trim() : String(value).trim();
return text ? text : undefined;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
return undefined;
}
const formatConfigArgs: CommandArgsFormatter = (values) => {
+7 -3
View File
@@ -137,9 +137,13 @@ function formatPositionalArgs(
for (const definition of definitions) {
const value = values[definition.name];
if (value == null) continue;
const rendered = typeof value === "string" ? value.trim() : String(value);
if (!rendered) continue;
parts.push(rendered);
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) continue;
parts.push(trimmed);
} else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
parts.push(String(value));
}
if (definition.captureRemaining) break;
}
return parts.length > 0 ? parts.join(" ") : undefined;
+22 -1
View File
@@ -78,11 +78,32 @@ export type TemplateContext = MsgContext & {
IsNewSession?: string;
};
function formatTemplateValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) return [];
if (typeof entry === "string") return [entry];
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
return [String(entry)];
}
return [];
})
.join(",");
}
return "";
}
// Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) return "";
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = ctx[key as keyof TemplateContext];
return value == null ? "" : String(value);
return formatTemplateValue(value);
});
}