diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 596d10b..c9e8b1d 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -52,6 +52,8 @@ import { } from './chat-task-intent-config.mjs'; import { extractAgentRunExperience } from './experience-extractor.mjs'; import { executeRainPipeline, isRainModeMessage } from './rain-service/index.mjs'; +import { buildContextBudgetResolvedEvent, resolveContextBudgetMode } from './context-budget.mjs'; +import { buildHeadroomRunObservation, resolveHeadroomMode } from './memind-headroom-policy.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -1946,6 +1948,11 @@ export function createAgentRunGateway({ sessionId: row.agent_session_id ?? null, }); await appendEvent(runId, 'intent_routed', routing); + if (resolveHeadroomMode() !== 'off') { + await appendEvent(runId, 'headroom_context_observed', buildHeadroomRunObservation({ + skillId: routing?.suggestedSkill ?? routing?.suggested_skill ?? null, + })); + } if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) { const grantedSkills = await resolveGrantedSkills(row.user_id); let goalContext = null; @@ -1997,11 +2004,32 @@ export function createAgentRunGateway({ } } if (!rainHandoff) { + let preparedContext = null; + if ( + resolveContextBudgetMode() !== 'off' && + chatIntentRouter?.prepareAgentOrchestrationContext + ) { + preparedContext = chatIntentRouter.prepareAgentOrchestrationContext( + userMessage, + routing, + { + grantedSkills, + memoryContext: agentMemoryContext, + goalContext, + runtimeContext, + }, + ); + const budgetEvent = buildContextBudgetResolvedEvent(preparedContext.budgetPlan); + if (budgetEvent) { + await appendEvent(runId, 'context_budget_resolved', budgetEvent); + } + } userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { grantedSkills, memoryContext: agentMemoryContext, goalContext, runtimeContext, + preparedContext, }); } } diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 81e477e..6979022 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -9,6 +9,7 @@ import { isProductCampaignIntent, PAGE_DATA_COLLECT_SKILL_NAME, } from './chat-skills.mjs'; +import { applyContextBudgetToOrchestration } from './context-budget.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { memoryLimitForIntervention, @@ -1175,20 +1176,56 @@ export function buildAgentOrchestrationAgentText({ return lines.join('\n'); } -export function applyAgentOrchestrationToUserMessage( +export function prepareAgentOrchestrationContext( userMessage, classification, { grantedSkills = [], memoryContext = null, goalContext = null, runtimeContext = null } = {}, ) { const displayText = messageDisplayText(userMessage); const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText); - const agentText = buildAgentOrchestrationAgentText({ - displayText, - classification, - skillPrompt, + const budgeted = applyContextBudgetToOrchestration({ memoryContext, goalContext, runtimeContext, + skillPrompt, + userTask: displayText, + }); + return { + displayText, + classification, + skillPrompt: budgeted.skillPrompt, + memoryContext: budgeted.memoryContext, + goalContext: budgeted.goalContext, + runtimeContext: budgeted.runtimeContext, + budgetPlan: budgeted.plan, + budgetApplied: budgeted.applied, + }; +} + +export function applyAgentOrchestrationToUserMessage( + userMessage, + classification, + { + grantedSkills = [], + memoryContext = null, + goalContext = null, + runtimeContext = null, + preparedContext = null, + } = {}, +) { + const prepared = preparedContext ?? prepareAgentOrchestrationContext(userMessage, classification, { + grantedSkills, + memoryContext, + goalContext, + runtimeContext, + }); + const agentText = buildAgentOrchestrationAgentText({ + displayText: prepared.displayText, + classification: prepared.classification, + skillPrompt: prepared.skillPrompt, + memoryContext: prepared.memoryContext, + goalContext: prepared.goalContext, + runtimeContext: prepared.runtimeContext, }); const content = Array.isArray(userMessage?.content) ? userMessage.content.map((item, index) => { @@ -1200,7 +1237,7 @@ export function applyAgentOrchestrationToUserMessage( : [{ type: 'text', text: agentText }]; const metadata = { ...(userMessage?.metadata ?? {}), - displayText: displayText || undefined, + displayText: prepared.displayText || undefined, chatIntentRoute: CHAT_INTENT_ROUTE.AGENT, chatIntentSource: classification?.source ?? null, userVisible: userMessage?.metadata?.userVisible ?? true, @@ -1901,6 +1938,7 @@ export function createChatIntentRouter(options = {}) { classifySessionAction, resolveAgentMemoryContext, applyAgentOrchestration: applyAgentOrchestrationToUserMessage, + prepareAgentOrchestrationContext, }; } @@ -2005,5 +2043,6 @@ export function createManagedChatIntentRouter({ }, applyAgentOrchestration: applyAgentOrchestrationToUserMessage, + prepareAgentOrchestrationContext, }; } diff --git a/context-budget.mjs b/context-budget.mjs new file mode 100644 index 0000000..0e64b1e --- /dev/null +++ b/context-budget.mjs @@ -0,0 +1,360 @@ +import crypto from 'node:crypto'; + +export const CONTEXT_BUDGET_MODES = Object.freeze(['off', 'shadow', 'active']); +export const DEFAULT_CONTEXT_BUDGET_MAX_CHARS = 12_000; + +const SOURCE_PRIORITY = Object.freeze({ + user_task: 100, + skill_prompt: 90, + goal: 85, + temporal: 75, + memory: 65, + harness: 50, +}); + +const HARNESS_TITLE_PRIORITY = Object.freeze({ + 'TKMind 用户空间沙箱': 95, + 'TKMind 当前时间基准': 94, + 'TKMind 代码委托策略': 88, + 'TKMind 日程写入规则': 82, + 'TKMind 定时自动任务规则': 82, + 'TKMind 长期记忆规则': 58, + 'TKMind 用户偏好画像': 57, + 'TKMind 已沉淀用户记忆': 56, +}); + +function normalizeMode(value, fallback = 'off') { + const raw = String(value ?? fallback).trim().toLowerCase(); + return CONTEXT_BUDGET_MODES.includes(raw) ? raw : fallback; +} + +export function resolveContextBudgetMode(env = process.env) { + return normalizeMode(env.MEMIND_CONTEXT_BUDGET_MODE, 'off'); +} + +export function resolveContextBudgetMaxChars(env = process.env) { + const raw = Number(env.MEMIND_CONTEXT_BUDGET_MAX_CHARS ?? DEFAULT_CONTEXT_BUDGET_MAX_CHARS); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_CONTEXT_BUDGET_MAX_CHARS; +} + +export function fingerprintContent(text) { + const normalized = String(text ?? '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (!normalized) return null; + return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); +} + +function blockPriority(block) { + const source = String(block?.source ?? '').trim(); + const base = SOURCE_PRIORITY[source] ?? 40; + if (source !== 'harness') return base; + const title = String(block?.title ?? block?.label ?? '').trim(); + return HARNESS_TITLE_PRIORITY[title] ?? base; +} + +function blockChars(block) { + return String(block?.text ?? block?.content ?? '').length; +} + +function normalizeMemoryBlock(item) { + const label = String(item?.label ?? item?.title ?? '').trim(); + const text = String(item?.text ?? item?.content ?? item?.summary ?? '').trim(); + if (!text) return null; + return { + source: 'memory', + id: String(item?.id ?? item?.memoryId ?? label ?? text.slice(0, 32)), + label: label || null, + text, + fingerprint: fingerprintContent(text), + priority: SOURCE_PRIORITY.memory, + chars: text.length, + }; +} + +function normalizeHarnessBlock(entry) { + const title = String(entry?.title ?? '').trim(); + const text = String(entry?.content ?? entry?.text ?? '').trim(); + if (!text) return null; + return { + source: 'harness', + id: title || text.slice(0, 32), + title: title || null, + text, + fingerprint: fingerprintContent(text), + priority: blockPriority({ source: 'harness', title }), + chars: text.length, + }; +} + +export function buildContextBudgetBlocks({ + memories = [], + temporalText = '', + goalEnvelope = '', + harnessEntries = [], + skillPrompt = '', + userTask = '', +} = {}) { + const blocks = []; + + const task = String(userTask ?? '').trim(); + if (task) { + blocks.push({ + source: 'user_task', + id: 'user_task', + text: task, + fingerprint: fingerprintContent(task), + priority: SOURCE_PRIORITY.user_task, + chars: task.length, + }); + } + + const skill = String(skillPrompt ?? '').trim(); + if (skill) { + blocks.push({ + source: 'skill_prompt', + id: 'skill_prompt', + text: skill, + fingerprint: fingerprintContent(skill), + priority: SOURCE_PRIORITY.skill_prompt, + chars: skill.length, + }); + } + + const goal = String(goalEnvelope ?? '').trim(); + if (goal) { + blocks.push({ + source: 'goal', + id: 'goal', + text: goal, + fingerprint: fingerprintContent(goal), + priority: SOURCE_PRIORITY.goal, + chars: goal.length, + }); + } + + const temporal = String(temporalText ?? '').trim(); + if (temporal) { + blocks.push({ + source: 'temporal', + id: 'temporal', + text: temporal, + fingerprint: fingerprintContent(temporal), + priority: SOURCE_PRIORITY.temporal, + chars: temporal.length, + }); + } + + for (const item of Array.isArray(memories) ? memories : []) { + const block = normalizeMemoryBlock(item); + if (block) blocks.push(block); + } + + for (const entry of Array.isArray(harnessEntries) ? harnessEntries : []) { + const block = normalizeHarnessBlock(entry); + if (block) blocks.push(block); + } + + return blocks; +} + +export function planContextBudget(input = {}, { maxChars, env = process.env } = {}) { + const mode = resolveContextBudgetMode(env); + const budget = Number.isFinite(maxChars) ? maxChars : resolveContextBudgetMaxChars(env); + const blocks = buildContextBudgetBlocks(input); + const seenFingerprints = new Set(); + const kept = []; + const droppedItems = []; + const duplicateItems = []; + + const sorted = [...blocks].sort((a, b) => { + const priorityDelta = blockPriority(b) - blockPriority(a); + if (priorityDelta !== 0) return priorityDelta; + return blockChars(a) - blockChars(b); + }); + + let totalChars = 0; + for (const block of sorted) { + if (block.source === 'user_task') { + kept.push(block); + totalChars += block.chars; + if (block.fingerprint) seenFingerprints.add(block.fingerprint); + continue; + } + + if (block.fingerprint && seenFingerprints.has(block.fingerprint)) { + duplicateItems.push({ + source: block.source, + id: block.id, + reason: 'duplicate_fingerprint', + fingerprint: block.fingerprint, + chars: block.chars, + }); + continue; + } + + if (totalChars + block.chars > budget) { + droppedItems.push({ + source: block.source, + id: block.id, + reason: 'over_budget', + fingerprint: block.fingerprint ?? null, + chars: block.chars, + priority: blockPriority(block), + }); + continue; + } + + kept.push(block); + totalChars += block.chars; + if (block.fingerprint) seenFingerprints.add(block.fingerprint); + } + + const fingerprints = [...seenFingerprints]; + return { + mode, + budget, + blocks: kept, + droppedItems, + duplicateItems, + totalChars, + fingerprints, + inputBlockCount: blocks.length, + keptBlockCount: kept.length, + }; +} + +export function applyContextBudgetToOrchestration({ + memoryContext = null, + goalContext = null, + runtimeContext = null, + skillPrompt = '', + userTask = '', + plan = null, + env = process.env, +} = {}) { + const mode = resolveContextBudgetMode(env); + if (mode === 'off') { + return { + memoryContext, + goalContext, + runtimeContext, + skillPrompt, + plan: null, + applied: false, + }; + } + + const resolvedPlan = plan ?? planContextBudget({ + memories: memoryContext?.injectionEnabled ? memoryContext.memories : [], + temporalText: runtimeContext?.injectionEnabled ? runtimeContext.injectionText : '', + goalEnvelope: goalContext?.injectionEnabled ? goalContext.envelope : '', + skillPrompt, + userTask, + }, { env }); + + if (mode === 'shadow') { + return { + memoryContext, + goalContext, + runtimeContext, + skillPrompt, + plan: resolvedPlan, + applied: false, + }; + } + + const keptBySource = { + memory: new Set(), + goal: false, + temporal: false, + skill_prompt: false, + }; + + for (const block of resolvedPlan.blocks) { + if (block.source === 'memory') keptBySource.memory.add(block.id); + if (block.source === 'goal') keptBySource.goal = true; + if (block.source === 'temporal') keptBySource.temporal = true; + if (block.source === 'skill_prompt') keptBySource.skill_prompt = true; + } + + const nextMemoryContext = memoryContext?.injectionEnabled + ? { + ...memoryContext, + memories: (Array.isArray(memoryContext.memories) ? memoryContext.memories : []) + .filter((item, index) => { + const label = String(item?.label ?? item?.title ?? '').trim(); + const text = String(item?.text ?? item?.content ?? item?.summary ?? '').trim(); + const id = String(item?.id ?? item?.memoryId ?? label ?? (text.slice(0, 32) || `memory-${index}`)); + return keptBySource.memory.has(id); + }), + } + : memoryContext; + + const nextGoalContext = goalContext?.injectionEnabled && !keptBySource.goal + ? { ...goalContext, injectionEnabled: false, envelope: '' } + : goalContext; + + const nextRuntimeContext = runtimeContext?.injectionEnabled && !keptBySource.temporal + ? { ...runtimeContext, injectionEnabled: false, injectionText: '' } + : runtimeContext; + + const nextSkillPrompt = keptBySource.skill_prompt ? skillPrompt : ''; + + return { + memoryContext: nextMemoryContext, + goalContext: nextGoalContext, + runtimeContext: nextRuntimeContext, + skillPrompt: nextSkillPrompt, + plan: resolvedPlan, + applied: true, + }; +} + +export function applyContextBudgetToHarnessEntries(entries = [], { plan = null, env = process.env } = {}) { + const mode = resolveContextBudgetMode(env); + if (mode !== 'active') { + return { + entries, + plan: plan ?? (mode === 'shadow' + ? planContextBudget({ harnessEntries: entries }, { env }) + : null), + applied: false, + }; + } + + const resolvedPlan = plan ?? planContextBudget({ harnessEntries: entries }, { env }); + const keptIds = new Set( + resolvedPlan.blocks + .filter((block) => block.source === 'harness') + .map((block) => block.id), + ); + + return { + entries: entries.filter((entry) => { + const title = String(entry?.title ?? '').trim(); + const text = String(entry?.content ?? entry?.text ?? '').trim(); + const id = title || text.slice(0, 32); + return keptIds.has(id); + }), + plan: resolvedPlan, + applied: true, + }; +} + +export function buildContextBudgetResolvedEvent(plan) { + if (!plan) return null; + return { + mode: plan.mode, + budget: plan.budget, + totalChars: plan.totalChars, + inputBlockCount: plan.inputBlockCount, + keptBlockCount: plan.keptBlockCount, + droppedCount: plan.droppedItems.length, + duplicateCount: plan.duplicateItems.length, + droppedItems: plan.droppedItems, + duplicateItems: plan.duplicateItems, + fingerprints: plan.fingerprints, + }; +} diff --git a/context-budget.test.mjs b/context-budget.test.mjs new file mode 100644 index 0000000..02ebbb1 --- /dev/null +++ b/context-budget.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyContextBudgetToHarnessEntries, + applyContextBudgetToOrchestration, + buildContextBudgetBlocks, + fingerprintContent, + planContextBudget, + resolveContextBudgetMode, +} from './context-budget.mjs'; + +test('resolveContextBudgetMode defaults to off', () => { + assert.equal(resolveContextBudgetMode({}), 'off'); + assert.equal(resolveContextBudgetMode({ MEMIND_CONTEXT_BUDGET_MODE: 'shadow' }), 'shadow'); + assert.equal(resolveContextBudgetMode({ MEMIND_CONTEXT_BUDGET_MODE: 'bogus' }), 'off'); +}); + +test('fingerprintContent normalizes whitespace and case', () => { + assert.equal( + fingerprintContent(' Hello World '), + fingerprintContent('hello world'), + ); +}); + +test('planContextBudget dedupes duplicate fingerprints across sources', () => { + const duplicate = '用户偏好:喜欢简洁回答'; + const plan = planContextBudget({ + memories: [{ label: '偏好', text: duplicate }], + harnessEntries: [{ title: 'TKMind 用户偏好画像', content: duplicate }], + userTask: '帮我总结今天的工作', + }, { + maxChars: 10_000, + env: { MEMIND_CONTEXT_BUDGET_MODE: 'shadow' }, + }); + + assert.equal(plan.duplicateItems.length, 1); + assert.equal(plan.keptBlockCount, plan.inputBlockCount - 1); +}); + +test('planContextBudget drops lower-priority blocks when over budget', () => { + const plan = planContextBudget({ + userTask: 'task', + skillPrompt: 'skill', + goalEnvelope: 'goal', + temporalText: 'temporal', + memories: [{ label: 'm1', text: 'memory-one' }], + harnessEntries: [{ title: 'TKMind 已沉淀用户记忆', content: 'stored-memory' }], + }, { + maxChars: 20, + env: { MEMIND_CONTEXT_BUDGET_MODE: 'active' }, + }); + + assert.ok(plan.droppedItems.length > 0); + assert.ok(plan.blocks.some((block) => block.source === 'user_task')); + assert.ok(plan.droppedItems.every((item) => item.source !== 'user_task')); +}); + +test('applyContextBudgetToOrchestration is no-op in shadow mode', () => { + const memoryContext = { + injectionEnabled: true, + memories: [{ label: 'a', text: 'alpha' }, { label: 'b', text: 'beta' }], + }; + const applied = applyContextBudgetToOrchestration({ + memoryContext, + userTask: 'do work', + env: { MEMIND_CONTEXT_BUDGET_MODE: 'shadow', MEMIND_CONTEXT_BUDGET_MAX_CHARS: '10' }, + }); + + assert.equal(applied.applied, false); + assert.equal(applied.memoryContext.memories.length, 2); + assert.ok(applied.plan); +}); + +test('applyContextBudgetToHarnessEntries filters in active mode', () => { + const entries = [ + { title: 'TKMind 当前时间基准', content: 'time anchor' }, + { title: 'TKMind 已沉淀用户记忆', content: 'long '.repeat(200) }, + ]; + const applied = applyContextBudgetToHarnessEntries(entries, { + env: { + MEMIND_CONTEXT_BUDGET_MODE: 'active', + MEMIND_CONTEXT_BUDGET_MAX_CHARS: '40', + }, + }); + + assert.equal(applied.applied, true); + assert.ok(applied.entries.length < entries.length); + assert.ok(applied.entries.some((entry) => entry.title === 'TKMind 当前时间基准')); +}); + +test('buildContextBudgetBlocks preserves source metadata', () => { + const blocks = buildContextBudgetBlocks({ + userTask: 'hello', + harnessEntries: [{ title: 'TKMind 用户空间沙箱', content: 'sandbox rules' }], + }); + assert.ok(blocks.some((block) => block.source === 'user_task')); + assert.ok(blocks.some((block) => block.source === 'harness' && block.priority >= 90)); +}); diff --git a/docs/goose-v149-canary.env.example b/docs/goose-v149-canary.env.example index 8eda161..3a65347 100644 --- a/docs/goose-v149-canary.env.example +++ b/docs/goose-v149-canary.env.example @@ -23,3 +23,13 @@ MEMORY_CANDIDATE_ENABLED=0 # Goose v1.49 local session DB (isolated from production memind_sessions) # GOOSE_SESSION_DB_URL=postgresql://john@127.0.0.1:5432/goose_sessions_v149_dev + +# Headroom context compression (Phase 1, local loopback only) +# MEMIND_HEADROOM_MODE=off +# MEMIND_HEADROOM_PROXY_BASE_URL=http://127.0.0.1:8787/v1 +# MEMIND_HEADROOM_UPSTREAM_BASE_URL=http://127.0.0.1:18036/v1 +# HEADROOM_OUTPUT_SHAPER=0 + +# Context injection budget (Phase 2, default off — does not open memory injection) +# MEMIND_CONTEXT_BUDGET_MODE=off +# MEMIND_CONTEXT_BUDGET_MAX_CHARS=12000 diff --git a/llm-providers.mjs b/llm-providers.mjs index b4b2300..b3b4d57 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -20,6 +20,11 @@ import { buildCursorExecutorLaunchPlan, cursorExecutorEnabled, } from './cursor-agent-launch.mjs'; +import { + probeHeadroomProxyReachable, + resolveGoosedApiUrlWithHeadroom, + resolveHeadroomMode, +} from './memind-headroom-policy.mjs'; export const MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID = 'custom_memind_deepseek_no_think'; @@ -885,13 +890,33 @@ async function syncDeepseekNoThinkProfileToGoosed(apiTarget, apiSecret, profile, ...(Array.isArray(profile.models) ? profile.models : []), ].filter(Boolean).map((item) => String(item).trim()).filter(Boolean)), ]; + const compatProxyBaseUrl = resolveDeepseekNoThinkProxyBaseUrl(); + const headroomMode = resolveHeadroomMode(); + const headroomReachable = headroomMode === 'off' + ? false + : await probeHeadroomProxyReachable({ fetchImpl }); + const headroomRouting = resolveGoosedApiUrlWithHeadroom({ + apiUrl: compatProxyBaseUrl, + mode: headroomMode, + headroomReachable, + eligible: true, + }); + if (headroomMode !== 'off') { + console.log('[llm-providers] headroom provider routing', { + mode: headroomRouting.mode, + routed: headroomRouting.routed, + fallback: headroomRouting.fallback ?? null, + wouldRouteTo: headroomRouting.wouldRouteTo ?? null, + apiUrl: headroomRouting.apiUrl, + }); + } const goosedProviderId = await upsertCustomProviderOnGoosed( apiTarget, apiSecret, { name: 'memind_deepseek_no_think', goosedProviderId: MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID, - apiUrl: resolveDeepseekNoThinkProxyBaseUrl(), + apiUrl: headroomRouting.apiUrl, apiKey: profile.apiKey, models, defaultModel: profile.defaultModel, diff --git a/memind-headroom-policy.mjs b/memind-headroom-policy.mjs new file mode 100644 index 0000000..c9e5606 --- /dev/null +++ b/memind-headroom-policy.mjs @@ -0,0 +1,146 @@ +import { + resolveDeepseekNoThinkProxyBaseUrl, +} from './deepseek-no-think-proxy.mjs'; + +export const DEFAULT_HEADROOM_PROXY_PORT = 8787; +export const MEMIND_HEADROOM_MODES = Object.freeze(['off', 'shadow', 'active']); + +export const DEFAULT_HEADROOM_EXCLUDED_SKILLS = Object.freeze([ + 'static-page-publish', + 'page-data-collect', + 'aider-development', +]); + +function normalizeMode(value, fallback = 'off') { + const raw = String(value ?? fallback).trim().toLowerCase(); + return MEMIND_HEADROOM_MODES.includes(raw) ? raw : fallback; +} + +export function resolveHeadroomMode(env = process.env) { + return normalizeMode(env.MEMIND_HEADROOM_MODE, 'off'); +} + +export function resolveHeadroomProxyPort(env = process.env) { + const port = Number(env.MEMIND_HEADROOM_PROXY_PORT ?? DEFAULT_HEADROOM_PROXY_PORT); + return Number.isFinite(port) && port > 0 ? port : DEFAULT_HEADROOM_PROXY_PORT; +} + +export function resolveHeadroomProxyBaseUrl(env = process.env) { + const explicit = String(env.MEMIND_HEADROOM_PROXY_BASE_URL ?? '').trim(); + if (explicit) return explicit.replace(/\/$/, ''); + const host = String(env.MEMIND_HEADROOM_PROXY_HOST ?? '127.0.0.1').trim() || '127.0.0.1'; + return `http://${host}:${resolveHeadroomProxyPort(env)}/v1`; +} + +export function resolveHeadroomUpstreamBaseUrl(env = process.env) { + const explicit = String(env.MEMIND_HEADROOM_UPSTREAM_BASE_URL ?? '').trim(); + if (explicit) return explicit.replace(/\/$/, ''); + return resolveDeepseekNoThinkProxyBaseUrl(env); +} + +export function parseHeadroomExcludedSkills(env = process.env) { + const raw = String(env.MEMIND_HEADROOM_EXCLUDED_SKILLS ?? '').trim(); + if (!raw) return [...DEFAULT_HEADROOM_EXCLUDED_SKILLS]; + return raw.split(',').map((item) => item.trim()).filter(Boolean); +} + +export function isHeadroomEligibleForSkill(skillId, env = process.env) { + if (resolveHeadroomMode(env) === 'off') return false; + const skill = String(skillId ?? '').trim(); + if (!skill) return true; + return !parseHeadroomExcludedSkills(env).includes(skill); +} + +/** + * Decide whether goosed should talk to headroom instead of the direct compat proxy. + * fail-open: active mode falls back to the original apiUrl when headroom is unreachable. + */ +export function resolveGoosedApiUrlWithHeadroom({ + apiUrl, + mode = resolveHeadroomMode(), + headroomReachable = false, + eligible = true, + env = process.env, +} = {}) { + const normalizedMode = normalizeMode(mode, 'off'); + const original = String(apiUrl ?? '').trim(); + if (!original || normalizedMode === 'off' || !eligible) { + return { + apiUrl: original, + mode: normalizedMode, + routed: false, + eligible, + }; + } + + const headroomBase = resolveHeadroomProxyBaseUrl(env); + if (normalizedMode === 'shadow') { + return { + apiUrl: original, + mode: 'shadow', + routed: false, + eligible: true, + wouldRouteTo: headroomBase, + upstreamBaseUrl: resolveHeadroomUpstreamBaseUrl(env), + }; + } + + if (!headroomReachable) { + return { + apiUrl: original, + mode: 'active', + routed: false, + eligible: true, + fallback: 'headroom_unreachable', + wouldRouteTo: headroomBase, + }; + } + + return { + apiUrl: headroomBase, + mode: 'active', + routed: true, + eligible: true, + upstreamBaseUrl: resolveHeadroomUpstreamBaseUrl(env), + }; +} + +export function buildHeadroomRunObservation({ skillId, env = process.env } = {}) { + const mode = resolveHeadroomMode(env); + const eligible = isHeadroomEligibleForSkill(skillId, env); + return { + mode, + eligible, + excludedSkills: parseHeadroomExcludedSkills(env), + proxyBaseUrl: mode !== 'off' ? resolveHeadroomProxyBaseUrl(env) : null, + upstreamBaseUrl: mode !== 'off' ? resolveHeadroomUpstreamBaseUrl(env) : null, + }; +} + +export async function probeHeadroomProxyReachable({ + baseUrl, + fetchImpl = fetch, + timeoutMs = 1500, + env = process.env, +} = {}) { + const root = String(baseUrl ?? resolveHeadroomProxyBaseUrl(env)) + .trim() + .replace(/\/v1\/?$/i, ''); + if (!root) return false; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${root}/v1/models`, { + method: 'GET', + signal: controller.signal, + headers: { Authorization: 'Bearer headroom-probe' }, + }); + // Proxy alive when it responds, even with auth/upstream errors. + return response.status > 0; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} diff --git a/memind-headroom-policy.test.mjs b/memind-headroom-policy.test.mjs new file mode 100644 index 0000000..57812e9 --- /dev/null +++ b/memind-headroom-policy.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildHeadroomRunObservation, + isHeadroomEligibleForSkill, + resolveGoosedApiUrlWithHeadroom, + resolveHeadroomMode, + resolveHeadroomProxyBaseUrl, +} from './memind-headroom-policy.mjs'; + +test('resolveHeadroomMode defaults to off and rejects unknown values', () => { + assert.equal(resolveHeadroomMode({}), 'off'); + assert.equal(resolveHeadroomMode({ MEMIND_HEADROOM_MODE: 'shadow' }), 'shadow'); + assert.equal(resolveHeadroomMode({ MEMIND_HEADROOM_MODE: 'bogus' }), 'off'); +}); + +test('resolveHeadroomProxyBaseUrl honors explicit override', () => { + assert.equal( + resolveHeadroomProxyBaseUrl({ MEMIND_HEADROOM_PROXY_BASE_URL: 'http://127.0.0.1:9999/v1' }), + 'http://127.0.0.1:9999/v1', + ); +}); + +test('isHeadroomEligibleForSkill excludes page and code skills by default', () => { + assert.equal(isHeadroomEligibleForSkill('web', { MEMIND_HEADROOM_MODE: 'shadow' }), true); + assert.equal( + isHeadroomEligibleForSkill('static-page-publish', { MEMIND_HEADROOM_MODE: 'shadow' }), + false, + ); + assert.equal( + isHeadroomEligibleForSkill('page-data-collect', { MEMIND_HEADROOM_MODE: 'active' }), + false, + ); +}); + +test('resolveGoosedApiUrlWithHeadroom is fail-open in active mode', () => { + const original = 'http://127.0.0.1:18036/v1'; + const shadow = resolveGoosedApiUrlWithHeadroom({ + apiUrl: original, + mode: 'shadow', + headroomReachable: false, + eligible: true, + env: { MEMIND_HEADROOM_PROXY_BASE_URL: 'http://127.0.0.1:8787/v1' }, + }); + assert.equal(shadow.apiUrl, original); + assert.equal(shadow.routed, false); + assert.equal(shadow.wouldRouteTo, 'http://127.0.0.1:8787/v1'); + + const activeFallback = resolveGoosedApiUrlWithHeadroom({ + apiUrl: original, + mode: 'active', + headroomReachable: false, + eligible: true, + env: { MEMIND_HEADROOM_PROXY_BASE_URL: 'http://127.0.0.1:8787/v1' }, + }); + assert.equal(activeFallback.apiUrl, original); + assert.equal(activeFallback.fallback, 'headroom_unreachable'); + + const active = resolveGoosedApiUrlWithHeadroom({ + apiUrl: original, + mode: 'active', + headroomReachable: true, + eligible: true, + env: { MEMIND_HEADROOM_PROXY_BASE_URL: 'http://127.0.0.1:8787/v1' }, + }); + assert.equal(active.apiUrl, 'http://127.0.0.1:8787/v1'); + assert.equal(active.routed, true); +}); + +test('buildHeadroomRunObservation reports skill eligibility', () => { + const observation = buildHeadroomRunObservation({ + skillId: 'static-page-publish', + env: { MEMIND_HEADROOM_MODE: 'shadow' }, + }); + assert.equal(observation.mode, 'shadow'); + assert.equal(observation.eligible, false); + assert.equal(observation.proxyBaseUrl, 'http://127.0.0.1:8787/v1'); +}); diff --git a/scripts/check-goosed-v149-portal-resume.mjs b/scripts/check-goosed-v149-portal-resume.mjs index b202d3b..758213b 100644 --- a/scripts/check-goosed-v149-portal-resume.mjs +++ b/scripts/check-goosed-v149-portal-resume.mjs @@ -7,6 +7,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { prepareGooseV149CheckEnv } from './goose-v149-canary.mjs'; +import { waitForAgentRunWorkerIdle } from './goose-v149-worker-idle.mjs'; import { createReporter, loginViaApi, @@ -47,7 +48,8 @@ async function createGoosedAgentRun(baseUrl, cookie, { sessionId, message }) { body: JSON.stringify({ request_id: requestId, session_id: sessionId, - force_deep_reasoning: process.env.GOOSE_V149_PORTAL_RESUME_FORCE_DEEP !== '0', + // Resume smoke validates session round-trip, not deep-reasoning latency. + force_deep_reasoning: process.env.GOOSE_V149_PORTAL_RESUME_FORCE_DEEP === '1', user_message: { id: crypto.randomUUID(), role: 'user', @@ -104,6 +106,10 @@ async function main() { return; } + await waitForAgentRunWorkerIdle(root, process.env, { + logPrefix: '[goose-v149-portal-resume]', + }); + const reporter = createReporter(); const username = process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john'; const password = diff --git a/scripts/check-headroom-proxy-local.mjs b/scripts/check-headroom-proxy-local.mjs new file mode 100644 index 0000000..97b9683 --- /dev/null +++ b/scripts/check-headroom-proxy-local.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Local headroom proxy smoke (loopback only). + * Requires: headroom proxy running with upstream pointing at DeepSeek compat proxy. + * + * Example: + * OPENAI_BASE_URL=http://127.0.0.1:18036/v1 HEADROOM_OUTPUT_SHAPER=0 headroom proxy --port 8787 + */ +import { + buildHeadroomRunObservation, + probeHeadroomProxyReachable, + resolveHeadroomMode, + resolveHeadroomProxyBaseUrl, + resolveHeadroomUpstreamBaseUrl, +} from '../memind-headroom-policy.mjs'; + +const mode = resolveHeadroomMode(process.env); +if (mode === 'off') { + console.log('HEADROOM_LOCAL_SKIP: MEMIND_HEADROOM_MODE=off'); + process.exit(0); +} + +const reachable = await probeHeadroomProxyReachable(); +const observation = buildHeadroomRunObservation({ skillId: 'web' }); + +console.log('HEADROOM_LOCAL_PROBE:'); +console.log(` mode=${mode}`); +console.log(` proxy=${resolveHeadroomProxyBaseUrl()}`); +console.log(` upstream=${resolveHeadroomUpstreamBaseUrl()}`); +console.log(` reachable=${reachable}`); +console.log(` observation=${JSON.stringify(observation)}`); + +if (!reachable) { + console.error('HEADROOM_LOCAL_FAIL: proxy unreachable'); + process.exit(1); +} + +console.log('HEADROOM_LOCAL_OK'); diff --git a/session-reconcile.mjs b/session-reconcile.mjs index df589ee..56ae8b2 100644 --- a/session-reconcile.mjs +++ b/session-reconcile.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { developerToolsFromPolicy } from './capabilities.mjs'; +import { applyContextBudgetToHarnessEntries } from './context-budget.mjs'; import { buildSessionMemoryEntries } from './user-memory-profile.mjs'; import { buildSandboxSessionConstraints } from './user-publish.mjs'; @@ -307,13 +308,23 @@ export async function reconcileAgentSession( }) : null; - const memoryEntries = buildSessionMemoryEntries({ + const rawMemoryEntries = buildSessionMemoryEntries({ workingDir, sessionPolicy, sandboxConstraints: sandboxText, userContext, userMemories, }); + const { entries: memoryEntries, plan: harnessBudgetPlan } = applyContextBudgetToHarnessEntries( + rawMemoryEntries, + ); + if (harnessBudgetPlan?.mode === 'shadow' && harnessBudgetPlan.duplicateItems.length > 0) { + console.log('[session-reconcile] context budget shadow', { + duplicateCount: harnessBudgetPlan.duplicateItems.length, + droppedCount: harnessBudgetPlan.droppedItems.length, + keptBlockCount: harnessBudgetPlan.keptBlockCount, + }); + } if (memoryEntries.length > 0) { for (const entry of memoryEntries) {