@saccolabs/tars 1.31.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -11
- package/context/skills/create-extension/SKILL.md +2 -2
- package/context/skills/manage-extensions/SKILL.md +3 -3
- package/dist/channels/discord/discord-channel.d.ts +4 -0
- package/dist/channels/discord/discord-channel.js +21 -3
- package/dist/channels/discord/discord-channel.js.map +1 -1
- package/dist/channels/discord/message-formatter.d.ts +1 -1
- package/dist/channels/discord/message-formatter.js +1 -1
- package/dist/cli/commands/quota.js +13 -48
- package/dist/cli/commands/quota.js.map +1 -1
- package/dist/cli/commands/refresh.js +40 -3
- package/dist/cli/commands/refresh.js.map +1 -1
- package/dist/cli/commands/setup.js +192 -467
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/index.js +3 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/config/config.d.ts +5 -10
- package/dist/config/config.js +21 -27
- package/dist/config/config.js.map +1 -1
- package/dist/memory/knowledge-store.js +10 -1
- package/dist/memory/knowledge-store.js.map +1 -1
- package/dist/memory/memory-manager.d.ts +0 -3
- package/dist/memory/memory-manager.js +26 -34
- package/dist/memory/memory-manager.js.map +1 -1
- package/dist/scripts/debug-cli.js +2 -2
- package/dist/scripts/debug-cli.js.map +1 -1
- package/dist/supervisor/heartbeat-service.js +1 -1
- package/dist/supervisor/heartbeat-service.js.map +1 -1
- package/dist/supervisor/main.js +37 -79
- package/dist/supervisor/main.js.map +1 -1
- package/dist/supervisor/mcp-bridge.d.ts +25 -0
- package/dist/supervisor/mcp-bridge.js +157 -0
- package/dist/supervisor/mcp-bridge.js.map +1 -0
- package/dist/supervisor/session-manager.d.ts +1 -1
- package/dist/supervisor/session-manager.js +1 -1
- package/dist/supervisor/supervisor.d.ts +14 -7
- package/dist/supervisor/supervisor.js +87 -29
- package/dist/supervisor/supervisor.js.map +1 -1
- package/dist/supervisor/{gemini-engine.d.ts → tars-engine.d.ts} +38 -33
- package/dist/supervisor/tars-engine.js +698 -0
- package/dist/supervisor/tars-engine.js.map +1 -0
- package/dist/tools/get-quota.d.ts +38 -12
- package/dist/tools/get-quota.js +37 -94
- package/dist/tools/get-quota.js.map +1 -1
- package/dist/tools/send-notification.d.ts +32 -7
- package/dist/tools/send-notification.js +31 -37
- package/dist/tools/send-notification.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/utils/brain-audit.js +4 -4
- package/dist/utils/brain-audit.js.map +1 -1
- package/dist/utils/migration-manager.d.ts +4 -0
- package/dist/utils/migration-manager.js +205 -0
- package/dist/utils/migration-manager.js.map +1 -0
- package/extensions/memory/dist/store.js +29 -20
- package/extensions/memory/dist/store.js.map +1 -1
- package/extensions/memory/src/store.ts +33 -23
- package/package.json +4 -3
- package/src/prompts/system.md +3 -14
- package/context/agents/scaffolder.md +0 -22
- package/dist/auth/credential-manager.d.ts +0 -14
- package/dist/auth/credential-manager.js +0 -60
- package/dist/auth/credential-manager.js.map +0 -1
- package/dist/auth/oauth-service.d.ts +0 -24
- package/dist/auth/oauth-service.js +0 -89
- package/dist/auth/oauth-service.js.map +0 -1
- package/dist/auth/workspace-auth-service.d.ts +0 -10
- package/dist/auth/workspace-auth-service.js +0 -78
- package/dist/auth/workspace-auth-service.js.map +0 -1
- package/dist/cli/commands/swarm.d.ts +0 -13
- package/dist/cli/commands/swarm.js +0 -250
- package/dist/cli/commands/swarm.js.map +0 -1
- package/dist/inference/LlamaCppGenerator.d.ts +0 -25
- package/dist/inference/LlamaCppGenerator.js +0 -461
- package/dist/inference/LlamaCppGenerator.js.map +0 -1
- package/dist/scripts/test-local-llamacpp.d.ts +0 -1
- package/dist/scripts/test-local-llamacpp.js +0 -77
- package/dist/scripts/test-local-llamacpp.js.map +0 -1
- package/dist/supervisor/gemini-engine.js +0 -1056
- package/dist/supervisor/gemini-engine.js.map +0 -1
- package/dist/swarm/agent-card.d.ts +0 -14
- package/dist/swarm/agent-card.js +0 -93
- package/dist/swarm/agent-card.js.map +0 -1
- package/dist/swarm/rpc-handler.d.ts +0 -27
- package/dist/swarm/rpc-handler.js +0 -235
- package/dist/swarm/rpc-handler.js.map +0 -1
- package/dist/swarm/swarm-service.d.ts +0 -47
- package/dist/swarm/swarm-service.js +0 -207
- package/dist/swarm/swarm-service.js.map +0 -1
- package/dist/swarm/types.d.ts +0 -109
- package/dist/swarm/types.js +0 -15
- package/dist/swarm/types.js.map +0 -1
- /package/extensions/memory/{gemini-extension.json → tars-extension.json} +0 -0
- /package/extensions/tasks/{gemini-extension.json → tars-extension.json} +0 -0
|
@@ -1,1056 +0,0 @@
|
|
|
1
|
-
import { Config as CoreConfig, GeminiEventType, AuthType, promptIdContext, Scheduler, ApprovalMode, PolicyDecision, SimpleExtensionLoader, MCPServerConfig, CompressionStatus, loadConversationRecord } from '@google/gemini-cli-core';
|
|
2
|
-
import { EventEmitter } from 'events';
|
|
3
|
-
import logger from '../utils/logger.js';
|
|
4
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
-
import fs from 'fs';
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import { SendNotificationTool } from '../tools/send-notification.js';
|
|
8
|
-
import { GetQuotaTool } from '../tools/get-quota.js';
|
|
9
|
-
import { LocalRateLimiter } from './rate-limiter.js';
|
|
10
|
-
import { LlamaCppGenerator } from '../inference/LlamaCppGenerator.js';
|
|
11
|
-
import { DLPService } from '../utils/dlp-service.js';
|
|
12
|
-
/**
|
|
13
|
-
* Detects the best authentication type based on environment variables.
|
|
14
|
-
* (Local implementation since it's not exported from core index)
|
|
15
|
-
*/
|
|
16
|
-
function getAuthTypeFromEnv() {
|
|
17
|
-
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
|
|
18
|
-
return AuthType.LOGIN_WITH_GOOGLE;
|
|
19
|
-
}
|
|
20
|
-
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
|
|
21
|
-
return AuthType.USE_VERTEX_AI;
|
|
22
|
-
}
|
|
23
|
-
if (process.env['GEMINI_API_KEY']) {
|
|
24
|
-
return AuthType.USE_GEMINI;
|
|
25
|
-
}
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* GeminiEngine - Native replacement for GeminiCli subprocess
|
|
30
|
-
*
|
|
31
|
-
* Uses @google/gemini-cli-core directly to interact with Gemini models.
|
|
32
|
-
* Operates within the ~/.tars isolated environment by overriding HOME.
|
|
33
|
-
*/
|
|
34
|
-
export class GeminiEngine extends EventEmitter {
|
|
35
|
-
tarsConfig;
|
|
36
|
-
coreConfig;
|
|
37
|
-
client;
|
|
38
|
-
initialized = false;
|
|
39
|
-
initializedWithFallback = false;
|
|
40
|
-
currentSessionId = null;
|
|
41
|
-
channelManager;
|
|
42
|
-
sessionManager;
|
|
43
|
-
rateLimiter;
|
|
44
|
-
constructor(tarsConfig) {
|
|
45
|
-
super();
|
|
46
|
-
this.tarsConfig = tarsConfig;
|
|
47
|
-
this.rateLimiter = new LocalRateLimiter(tarsConfig.maxRPM || 14, tarsConfig.maxTPM || 900000);
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Provide the ChannelManager instance to the engine so it can build proactive notification tools
|
|
51
|
-
*/
|
|
52
|
-
setChannelManager(channelManager) {
|
|
53
|
-
this.channelManager = channelManager;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Provide the SessionManager instance to the engine for session-aware tools
|
|
57
|
-
*/
|
|
58
|
-
setSessionManager(sessionManager) {
|
|
59
|
-
this.sessionManager = sessionManager;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Initializes the core Gemini client with proper auth and config.
|
|
63
|
-
*/
|
|
64
|
-
async initialize(initialSessionId) {
|
|
65
|
-
if (this.initialized)
|
|
66
|
-
return;
|
|
67
|
-
logger.info('🚀 Initializing Gemini Engine (Native Core)...');
|
|
68
|
-
const savedHome = process.env.HOME;
|
|
69
|
-
try {
|
|
70
|
-
// Ensure home directory exists
|
|
71
|
-
if (!fs.existsSync(this.tarsConfig.homeDir)) {
|
|
72
|
-
fs.mkdirSync(this.tarsConfig.homeDir, { recursive: true });
|
|
73
|
-
}
|
|
74
|
-
// Isolating to ~/.tars
|
|
75
|
-
process.env.HOME = this.tarsConfig.homeDir;
|
|
76
|
-
process.env.GEMINI_CLI_HOME = this.tarsConfig.homeDir;
|
|
77
|
-
// Tell the Gemini Core PromptProvider to use our custom system.md
|
|
78
|
-
const systemMdPath = path.join(this.tarsConfig.homeDir, '.gemini', 'system.md');
|
|
79
|
-
process.env.GEMINI_SYSTEM_MD = systemMdPath;
|
|
80
|
-
let authType = getAuthTypeFromEnv() || AuthType.LOGIN_WITH_GOOGLE;
|
|
81
|
-
// Prevent interactive Google login prompt if using local inference
|
|
82
|
-
if (this.tarsConfig.inferenceBackend === 'llamacpp') {
|
|
83
|
-
authType = AuthType.USE_GEMINI;
|
|
84
|
-
if (!process.env.GEMINI_API_KEY) {
|
|
85
|
-
process.env.GEMINI_API_KEY = 'dummy_llama_key_to_bypass_sdk_auth';
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const discoveredExtensions = await this.discoverExtensions();
|
|
89
|
-
const extensionLoader = new SimpleExtensionLoader(discoveredExtensions);
|
|
90
|
-
this.coreConfig = new CoreConfig({
|
|
91
|
-
sessionId: initialSessionId || uuidv4(),
|
|
92
|
-
targetDir: this.tarsConfig.homeDir,
|
|
93
|
-
cwd: this.tarsConfig.homeDir,
|
|
94
|
-
model: this.tarsConfig.geminiModel,
|
|
95
|
-
debugMode: false,
|
|
96
|
-
approvalMode: ApprovalMode.YOLO,
|
|
97
|
-
disableModelRouterForAuth: this.tarsConfig.inferenceBackend === 'llamacpp'
|
|
98
|
-
? [AuthType.USE_GEMINI]
|
|
99
|
-
: undefined,
|
|
100
|
-
policyEngineConfig: {
|
|
101
|
-
defaultDecision: PolicyDecision.ALLOW
|
|
102
|
-
},
|
|
103
|
-
interactive: true,
|
|
104
|
-
enableHooks: true,
|
|
105
|
-
mcpEnabled: true,
|
|
106
|
-
extensionsEnabled: true,
|
|
107
|
-
enableAgents: true,
|
|
108
|
-
skillsSupport: true,
|
|
109
|
-
adminSkillsEnabled: true,
|
|
110
|
-
noBrowser: true,
|
|
111
|
-
folderTrust: true,
|
|
112
|
-
trustedFolder: true,
|
|
113
|
-
extensionLoader
|
|
114
|
-
});
|
|
115
|
-
await this.coreConfig.refreshAuth(authType);
|
|
116
|
-
await this.coreConfig.initialize();
|
|
117
|
-
// Handle Local Inference Override
|
|
118
|
-
if (this.tarsConfig.inferenceBackend === 'llamacpp') {
|
|
119
|
-
logger.info(`🔌 Overriding Gemini Core with Local Inference: ${this.tarsConfig.localInferenceUrl}`);
|
|
120
|
-
const localGenerator = new LlamaCppGenerator(this.tarsConfig.localInferenceUrl);
|
|
121
|
-
// Override the content generator at runtime to bypass the SDK's internal Gemini calls
|
|
122
|
-
this.coreConfig.contentGenerator = localGenerator;
|
|
123
|
-
// We'll apply more overrides after the client is created
|
|
124
|
-
}
|
|
125
|
-
// Register system prompt template for tars-request
|
|
126
|
-
const promptProvider = this.coreConfig.promptProvider;
|
|
127
|
-
if (promptProvider) {
|
|
128
|
-
promptProvider.registerPrompt('tars-request', {
|
|
129
|
-
template: fs.readFileSync(systemMdPath, 'utf-8'),
|
|
130
|
-
includeContext: true,
|
|
131
|
-
includeTools: true,
|
|
132
|
-
includeHistory: true
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
// Inject native tools
|
|
136
|
-
const toolRegistry = this.coreConfig.getToolRegistry();
|
|
137
|
-
if (this.channelManager) {
|
|
138
|
-
const notifyTool = new SendNotificationTool(this.channelManager);
|
|
139
|
-
toolRegistry.registerTool(notifyTool);
|
|
140
|
-
logger.info('🔌 Registered native tool: send_notification');
|
|
141
|
-
}
|
|
142
|
-
const getQuotaTool = new GetQuotaTool(this.coreConfig, this.sessionManager, {
|
|
143
|
-
inferenceBackend: this.tarsConfig.inferenceBackend,
|
|
144
|
-
contextWindowTokens: this.tarsConfig.contextWindowTokens,
|
|
145
|
-
geminiModel: this.tarsConfig.geminiModel,
|
|
146
|
-
localInferenceUrl: this.tarsConfig.localInferenceUrl
|
|
147
|
-
});
|
|
148
|
-
toolRegistry.registerTool(getQuotaTool);
|
|
149
|
-
logger.info('🔌 Registered native tool: get_model_quota');
|
|
150
|
-
this.client = this.coreConfig.getGeminiClient();
|
|
151
|
-
this.applyClientOverrides(this.client);
|
|
152
|
-
// Deregister plan-mode tools — they require interactive user confirmation
|
|
153
|
-
// that Tars cannot provide (non-interactive agent). Without this, the model
|
|
154
|
-
// calls enter_plan_mode which switches ApprovalMode to PLAN, but exit_plan_mode
|
|
155
|
-
// silently fails ("Rejected (no feedback)"), leaving the agent permanently
|
|
156
|
-
// stuck in PLAN mode. This forces all requests through the rate-limited
|
|
157
|
-
// gemini-3.1-pro-preview model, exhausting quota instantly.
|
|
158
|
-
try {
|
|
159
|
-
toolRegistry.unregisterTool('enter_plan_mode');
|
|
160
|
-
toolRegistry.unregisterTool('exit_plan_mode');
|
|
161
|
-
logger.info('🔇 Deregistered plan-mode tools (non-interactive agent)');
|
|
162
|
-
}
|
|
163
|
-
catch (e) {
|
|
164
|
-
logger.debug(`Plan-mode tool deregistration skipped: ${e.message}`);
|
|
165
|
-
}
|
|
166
|
-
this.initialized = true;
|
|
167
|
-
this.currentSessionId = this.coreConfig.getSessionId();
|
|
168
|
-
logger.info('✨ Gemini Engine initialized successfully.');
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
logger.error(`❌ Failed to initialize Gemini Engine: ${error.message}`);
|
|
172
|
-
throw error;
|
|
173
|
-
}
|
|
174
|
-
finally {
|
|
175
|
-
process.env.HOME = savedHome;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Discovers extensions from the ~/.tars/.gemini/extensions directory.
|
|
180
|
-
*/
|
|
181
|
-
async discoverExtensions() {
|
|
182
|
-
const extensionsDir = path.join(this.tarsConfig.homeDir, '.gemini', 'extensions');
|
|
183
|
-
if (!fs.existsSync(extensionsDir))
|
|
184
|
-
return [];
|
|
185
|
-
const extensions = [];
|
|
186
|
-
try {
|
|
187
|
-
const entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
|
|
188
|
-
for (const entry of entries) {
|
|
189
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
190
|
-
continue;
|
|
191
|
-
const extPath = path.resolve(extensionsDir, entry.name);
|
|
192
|
-
const configPath = path.join(extPath, 'gemini-extension.json');
|
|
193
|
-
if (fs.existsSync(configPath)) {
|
|
194
|
-
try {
|
|
195
|
-
const content = fs.readFileSync(configPath, 'utf-8');
|
|
196
|
-
const config = JSON.parse(content);
|
|
197
|
-
// Ensure mcpServers are converted to MCPServerConfig instances if they exist
|
|
198
|
-
const mcpServers = {};
|
|
199
|
-
if (config.mcpServers) {
|
|
200
|
-
for (const [name, srv] of Object.entries(config.mcpServers)) {
|
|
201
|
-
const s = srv;
|
|
202
|
-
// Manually resolve ${extensionPath} since we are constructing configs early
|
|
203
|
-
const resolvedArgs = s.args?.map((arg) => arg.replace(/\${extensionPath}/g, extPath));
|
|
204
|
-
const resolvedEnv = s.env ? { ...s.env } : {};
|
|
205
|
-
for (const key in resolvedEnv) {
|
|
206
|
-
resolvedEnv[key] = resolvedEnv[key].replace(/\${extensionPath}/g, extPath);
|
|
207
|
-
}
|
|
208
|
-
mcpServers[name] = new MCPServerConfig(s.command, resolvedArgs, resolvedEnv, s.cwd?.replace(/\${extensionPath}/g, extPath), s.url?.replace(/\${extensionPath}/g, extPath), s.httpUrl?.replace(/\${extensionPath}/g, extPath), s.headers, s.tcp, s.type, s.timeout, s.trust);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
extensions.push({
|
|
212
|
-
...config,
|
|
213
|
-
id: config.name,
|
|
214
|
-
path: extPath,
|
|
215
|
-
isActive: true,
|
|
216
|
-
mcpServers,
|
|
217
|
-
contextFiles: config.contextFiles || []
|
|
218
|
-
});
|
|
219
|
-
logger.info(`🔌 Found extension: ${config.name}`);
|
|
220
|
-
}
|
|
221
|
-
catch (e) {
|
|
222
|
-
logger.error(`Failed to parse extension at ${extPath}: ${e}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
catch (error) {
|
|
228
|
-
logger.error(`Error during extension discovery: ${error}`);
|
|
229
|
-
}
|
|
230
|
-
return extensions;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Executes a prompt and streams events back.
|
|
234
|
-
*/
|
|
235
|
-
async run(prompt, onEvent, sessionId, attachments, onStatus) {
|
|
236
|
-
if (!this.initialized) {
|
|
237
|
-
await this.initialize(sessionId);
|
|
238
|
-
}
|
|
239
|
-
const sid = sessionId || this.coreConfig.getSessionId();
|
|
240
|
-
const savedHome = process.env.HOME;
|
|
241
|
-
try {
|
|
242
|
-
process.env.HOME = this.tarsConfig.homeDir;
|
|
243
|
-
process.env.GEMINI_CLI_HOME = this.tarsConfig.homeDir;
|
|
244
|
-
// Session Swapping Logic or First Run
|
|
245
|
-
// We must call startChat at least once to initialize the GeminiChat session,
|
|
246
|
-
// even if the sessionId matches the coreConfig's initial ID.
|
|
247
|
-
if (this.currentSessionId !== sid || !this.client.isInitialized()) {
|
|
248
|
-
logger.debug(`🔄 Initializing/Swapping Gemini session to: ${sid}`);
|
|
249
|
-
const resumedData = await this.loadResumedSessionData(sid);
|
|
250
|
-
let history = undefined;
|
|
251
|
-
if (resumedData && resumedData.conversation) {
|
|
252
|
-
history = this.convertRecordToHistory(resumedData.conversation);
|
|
253
|
-
}
|
|
254
|
-
// @ts-ignore - access private to swap session
|
|
255
|
-
await this.client.startChat(history, resumedData || undefined);
|
|
256
|
-
// Sync: Read back the actual session ID that Core assigned.
|
|
257
|
-
// Core may create a new session ID internally (e.g. if the project hash
|
|
258
|
-
// changed or the old session was not found). We must keep Tars's
|
|
259
|
-
// SessionManager in sync to prevent ID mismatch on next restart.
|
|
260
|
-
const recordingService = this.client.getChatRecordingService();
|
|
261
|
-
const actualCoreSessionId = recordingService?.sessionId || this.coreConfig.getSessionId();
|
|
262
|
-
if (actualCoreSessionId && actualCoreSessionId !== sid) {
|
|
263
|
-
logger.warn(`⚠️ Session ID mismatch detected: Tars=${sid}, Core=${actualCoreSessionId}. Syncing to Core's ID.`);
|
|
264
|
-
this.currentSessionId = actualCoreSessionId;
|
|
265
|
-
// Update SessionManager so the correct ID is persisted to disk
|
|
266
|
-
if (this.sessionManager) {
|
|
267
|
-
await this.sessionManager.save(actualCoreSessionId);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
this.currentSessionId = sid;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
let currentRequestParts = [{ text: prompt }];
|
|
275
|
-
const reqPromptId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
276
|
-
// Handle Multimodal Attachments
|
|
277
|
-
if (attachments && attachments.length > 0) {
|
|
278
|
-
for (const attachment of attachments) {
|
|
279
|
-
try {
|
|
280
|
-
const data = fs.readFileSync(attachment.path).toString('base64');
|
|
281
|
-
currentRequestParts.push({
|
|
282
|
-
inlineData: {
|
|
283
|
-
data,
|
|
284
|
-
mimeType: attachment.mimeType
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
logger.debug(`📎 Attached file to prompt: ${attachment.path} (${attachment.mimeType})`);
|
|
288
|
-
}
|
|
289
|
-
catch (err) {
|
|
290
|
-
logger.error(`Failed to read attachment ${attachment.path}: ${err.message}`);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
let turnCount = 0;
|
|
295
|
-
const maxTurns = 100; // Increased to handle complex autonomous tasks
|
|
296
|
-
const abortController = new AbortController();
|
|
297
|
-
let finalUsageStats = undefined;
|
|
298
|
-
// Accumulate usage across multi-turn interactions.
|
|
299
|
-
// For local models, each turn reports only its own tokens. For Gemini Cloud,
|
|
300
|
-
// promptTokenCount is cumulative (total context). We keep the maximum
|
|
301
|
-
// promptTokenCount seen (last turn = largest context) and sum outputTokens.
|
|
302
|
-
let accumulatedInputTokens = 0;
|
|
303
|
-
let accumulatedOutputTokens = 0;
|
|
304
|
-
let accumulatedCachedTokens = 0;
|
|
305
|
-
let loopDetected = false;
|
|
306
|
-
let hasRealContent = false;
|
|
307
|
-
// Track recent tool calls for live status updates
|
|
308
|
-
const recentTools = [];
|
|
309
|
-
while (turnCount < maxTurns) {
|
|
310
|
-
turnCount++;
|
|
311
|
-
const toolRequests = [];
|
|
312
|
-
let stream;
|
|
313
|
-
let retryCount = 0;
|
|
314
|
-
const maxRetries = 8;
|
|
315
|
-
let lastError = null;
|
|
316
|
-
while (retryCount < maxRetries) {
|
|
317
|
-
try {
|
|
318
|
-
const estimatedTokens = Math.max(100, accumulatedInputTokens);
|
|
319
|
-
const waitTime = this.rateLimiter.checkWaitTime(estimatedTokens);
|
|
320
|
-
if (waitTime > 0) {
|
|
321
|
-
logger.info(`⏳ Pre-emptive throttling: waiting ${Math.round(waitTime / 1000)}s to avoid rate limits...`);
|
|
322
|
-
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
323
|
-
}
|
|
324
|
-
this.rateLimiter.recordRequest(estimatedTokens);
|
|
325
|
-
stream = await promptIdContext.run(sid, () => {
|
|
326
|
-
// Combine manual abort signal with a 5-minute timeout to prevent deadlock
|
|
327
|
-
const timeoutSignal = AbortSignal.timeout(5 * 60 * 1000);
|
|
328
|
-
const combinedSignal = abortController.signal.aborted
|
|
329
|
-
? abortController.signal
|
|
330
|
-
: timeoutSignal;
|
|
331
|
-
// We listen for manual aborts to also abort the combined signal
|
|
332
|
-
// (AbortSignal.any is available in Node 20+, but we can just pass timeoutSignal
|
|
333
|
-
// and manual aborts are rare here, or use AbortSignal.any if supported)
|
|
334
|
-
const signalToUse = typeof AbortSignal.any === 'function'
|
|
335
|
-
? AbortSignal.any([abortController.signal, timeoutSignal])
|
|
336
|
-
: timeoutSignal;
|
|
337
|
-
return this.client.sendMessageStream(currentRequestParts, signalToUse, reqPromptId);
|
|
338
|
-
});
|
|
339
|
-
break; // Success
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
retryCount++;
|
|
343
|
-
lastError = error;
|
|
344
|
-
const isTransient = error.message?.includes('429') ||
|
|
345
|
-
error.message?.includes('503') ||
|
|
346
|
-
error.message?.toLowerCase().includes('rate limit') ||
|
|
347
|
-
error.message?.toLowerCase().includes('capacity') ||
|
|
348
|
-
error.message?.toLowerCase().includes('quota') ||
|
|
349
|
-
error.message?.toLowerCase().includes('overloaded');
|
|
350
|
-
if (isTransient && retryCount < maxRetries) {
|
|
351
|
-
const delay = Math.pow(2, retryCount) * 1000 + Math.random() * 1000;
|
|
352
|
-
logger.warn(`⚠️ Gemini API transient error (attempt ${retryCount}/${maxRetries}): ${error.message}. Retrying in ${Math.round(delay)}ms...`);
|
|
353
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
// Fallback logic for 'auto' model on permanent error or final retry
|
|
357
|
-
if (this.tarsConfig.geminiModel === 'auto' &&
|
|
358
|
-
!this.initializedWithFallback) {
|
|
359
|
-
logger.warn(`🔄 'auto' model failed with error: ${error.message}. Attempting fallback to gemini-2.0-flash...`);
|
|
360
|
-
this.initializedWithFallback = true;
|
|
361
|
-
// @ts-ignore - modifying private config for fallback
|
|
362
|
-
this.coreConfig.model = 'gemini-2.0-flash';
|
|
363
|
-
// Re-initialize client with new model
|
|
364
|
-
this.client = this.coreConfig.getGeminiClient();
|
|
365
|
-
this.applyClientOverrides(this.client);
|
|
366
|
-
await this.client.initialize();
|
|
367
|
-
retryCount = 0; // Reset retries for the fallback model
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
throw error;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
for await (const event of stream) {
|
|
374
|
-
if (event.type === GeminiEventType.ToolCallRequest) {
|
|
375
|
-
toolRequests.push(event.value);
|
|
376
|
-
}
|
|
377
|
-
if (event.type === GeminiEventType.LoopDetected) {
|
|
378
|
-
loopDetected = true;
|
|
379
|
-
}
|
|
380
|
-
if (event.type === GeminiEventType.Finished) {
|
|
381
|
-
const usage = event.value.usageMetadata;
|
|
382
|
-
if (usage) {
|
|
383
|
-
// promptTokenCount reflects total context size (cumulative)
|
|
384
|
-
// so we always take the latest (highest) value
|
|
385
|
-
if (usage.promptTokenCount) {
|
|
386
|
-
accumulatedInputTokens = Math.max(accumulatedInputTokens, usage.promptTokenCount);
|
|
387
|
-
}
|
|
388
|
-
// candidatesTokenCount is per-turn, so we accumulate
|
|
389
|
-
if (usage.candidatesTokenCount) {
|
|
390
|
-
accumulatedOutputTokens += usage.candidatesTokenCount;
|
|
391
|
-
}
|
|
392
|
-
if (usage.cachedContentTokenCount) {
|
|
393
|
-
accumulatedCachedTokens = Math.max(accumulatedCachedTokens, usage.cachedContentTokenCount);
|
|
394
|
-
}
|
|
395
|
-
finalUsageStats = usage;
|
|
396
|
-
}
|
|
397
|
-
continue; // Don't emit done yet
|
|
398
|
-
}
|
|
399
|
-
const normalized = this.normalizeEvent(event, sid);
|
|
400
|
-
if (normalized) {
|
|
401
|
-
if (normalized.type === 'text' && normalized.content?.trim()) {
|
|
402
|
-
hasRealContent = true;
|
|
403
|
-
}
|
|
404
|
-
await onEvent(normalized);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (loopDetected) {
|
|
408
|
-
logger.warn(`⚠️ Loop detected in Gemini Engine at turn ${turnCount}.`);
|
|
409
|
-
break;
|
|
410
|
-
}
|
|
411
|
-
if (toolRequests.length === 0) {
|
|
412
|
-
logger.debug(`✅ Interaction complete after ${turnCount} turns.`);
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
// ... (rest of the loop)
|
|
416
|
-
if (turnCount >= maxTurns) {
|
|
417
|
-
logger.warn(`⚠️ Hit maxTurns (${maxTurns}) limit. Force terminating interaction.`);
|
|
418
|
-
await onEvent({
|
|
419
|
-
type: 'text',
|
|
420
|
-
role: 'assistant',
|
|
421
|
-
content: '\n\n⚠️ *Task was complex and reached the maximum turn limit. I have executed as much as I could.*',
|
|
422
|
-
sessionId: sid
|
|
423
|
-
});
|
|
424
|
-
break;
|
|
425
|
-
}
|
|
426
|
-
// Runtime Safety Filter: Prevent self-destructive commands and unauthorized path access
|
|
427
|
-
const filteredToolRequests = [];
|
|
428
|
-
const blockedResponses = new Map();
|
|
429
|
-
const sensitiveCalls = new Set();
|
|
430
|
-
for (const req of toolRequests) {
|
|
431
|
-
const toolName = req.name;
|
|
432
|
-
const args = req.args || {};
|
|
433
|
-
const commandLine = args.CommandLine || args.command || '';
|
|
434
|
-
const filePath = args.file_path || args.path || args.dir_path || '';
|
|
435
|
-
// 1. Block self-destructive commands
|
|
436
|
-
if ((toolName.includes('run_command') ||
|
|
437
|
-
toolName.includes('run_shell_command')) &&
|
|
438
|
-
(commandLine.includes('tars stop') ||
|
|
439
|
-
/\bpm2\s+(stop|kill|delete)\b/.test(commandLine))) {
|
|
440
|
-
logger.warn(`🛑 INTERCEPTED self-destructive command: ${commandLine}`);
|
|
441
|
-
await onEvent({
|
|
442
|
-
type: 'text',
|
|
443
|
-
role: 'assistant',
|
|
444
|
-
content: `\n\n⚠️ **Safety Interruption**: I attempted to run a command that would stop my own supervisor process (${commandLine}). To prevent a loss of connection or state, I have blocked this action. If you really want me to stop, please run \`tars stop\` manually in your terminal.`,
|
|
445
|
-
sessionId: sid
|
|
446
|
-
});
|
|
447
|
-
blockedResponses.set(req.callId, 'Execution blocked: Self-destructive command detected.');
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
// 2. Block blacklisted path access
|
|
451
|
-
if (filePath && DLPService.isPathBlacklisted(filePath)) {
|
|
452
|
-
logger.warn(`🛑 INTERCEPTED unauthorized path access: ${filePath}`);
|
|
453
|
-
await onEvent({
|
|
454
|
-
type: 'text',
|
|
455
|
-
role: 'assistant',
|
|
456
|
-
content: `\n\n⚠️ **Security Interruption**: I attempted to access a protected file or directory (${filePath}). Access to this path is restricted by the Tars Data Loss Prevention (DLP) policy.`,
|
|
457
|
-
sessionId: sid
|
|
458
|
-
});
|
|
459
|
-
blockedResponses.set(req.callId, `Access to ${filePath} is restricted by DLP policy.`);
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
// 3. Mark sensitive paths for aggressive scrubbing
|
|
463
|
-
if ((filePath && DLPService.isSensitivePath(filePath)) ||
|
|
464
|
-
(commandLine && DLPService.isSensitivePath(commandLine))) {
|
|
465
|
-
sensitiveCalls.add(req.callId);
|
|
466
|
-
}
|
|
467
|
-
filteredToolRequests.push(req);
|
|
468
|
-
}
|
|
469
|
-
// Execute tools using Scheduler
|
|
470
|
-
let completedCalls = [];
|
|
471
|
-
if (filteredToolRequests.length > 0) {
|
|
472
|
-
logger.debug(`🛠️ Executing ${filteredToolRequests.length} tool calls...`);
|
|
473
|
-
const scheduler = new Scheduler({
|
|
474
|
-
context: this.coreConfig,
|
|
475
|
-
messageBus: this.coreConfig.getMessageBus(),
|
|
476
|
-
getPreferredEditor: () => undefined,
|
|
477
|
-
schedulerId: sid
|
|
478
|
-
});
|
|
479
|
-
completedCalls = await scheduler.schedule(filteredToolRequests, abortController.signal);
|
|
480
|
-
// Emit tool responses so the Supervisor can log them
|
|
481
|
-
for (const call of completedCalls) {
|
|
482
|
-
const normalized = this.normalizeEvent({
|
|
483
|
-
type: GeminiEventType.ToolCallResponse,
|
|
484
|
-
value: call
|
|
485
|
-
}, sid);
|
|
486
|
-
if (normalized)
|
|
487
|
-
await onEvent(normalized);
|
|
488
|
-
}
|
|
489
|
-
// Record results in chat recording service for persistence/memory
|
|
490
|
-
const model = this.tarsConfig.geminiModel;
|
|
491
|
-
this.client.getChat().recordCompletedToolCalls(model, completedCalls);
|
|
492
|
-
}
|
|
493
|
-
// Build tool status for live progress updates
|
|
494
|
-
const turnToolStatuses = [];
|
|
495
|
-
for (const call of completedCalls) {
|
|
496
|
-
const req = toolRequests.find((r) => r.callId === call.request?.callId || r.callId === call.callId);
|
|
497
|
-
if (!req)
|
|
498
|
-
continue;
|
|
499
|
-
const part = call.response?.responseParts?.find((p) => 'functionResponse' in p);
|
|
500
|
-
const response = part?.functionResponse?.response;
|
|
501
|
-
const responseStr = typeof response === 'string'
|
|
502
|
-
? response
|
|
503
|
-
: typeof response === 'object'
|
|
504
|
-
? JSON.stringify(response)
|
|
505
|
-
: String(response || '');
|
|
506
|
-
turnToolStatuses.push({
|
|
507
|
-
name: req.name,
|
|
508
|
-
responsePreview: responseStr.substring(0, 120),
|
|
509
|
-
responseSize: responseStr.length
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
// Also include blocked tools
|
|
513
|
-
for (const [callId, reason] of blockedResponses) {
|
|
514
|
-
const req = toolRequests.find((r) => r.callId === callId);
|
|
515
|
-
if (req) {
|
|
516
|
-
turnToolStatuses.push({
|
|
517
|
-
name: req.name,
|
|
518
|
-
responsePreview: `⛔ ${reason}`,
|
|
519
|
-
responseSize: reason.length
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
recentTools.push(...turnToolStatuses);
|
|
524
|
-
const isMilestone = turnCount > 0 && turnCount % 20 === 0;
|
|
525
|
-
// Fire status update after each tool batch or on a milestone
|
|
526
|
-
if (onStatus && (turnToolStatuses.length > 0 || isMilestone)) {
|
|
527
|
-
if (isMilestone) {
|
|
528
|
-
logger.info(`[GeminiEngine] Milestone ${turnCount} — firing status update...`);
|
|
529
|
-
}
|
|
530
|
-
try {
|
|
531
|
-
await onStatus(turnCount, recentTools.slice(-10), isMilestone);
|
|
532
|
-
}
|
|
533
|
-
catch (e) {
|
|
534
|
-
logger.warn(`[GeminiEngine] Status update failed: ${e.message}`);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
// Prepare next request with tool results (Scrubbed via DLP and mapped back to 1:1)
|
|
538
|
-
currentRequestParts = GeminiEngine.buildToolResponseParts(toolRequests, completedCalls, blockedResponses, sensitiveCalls, this.tarsConfig.inferenceBackend === 'llamacpp');
|
|
539
|
-
if (currentRequestParts.length === 0) {
|
|
540
|
-
logger.warn('⚠️ No tool responses generated after execution.');
|
|
541
|
-
break;
|
|
542
|
-
}
|
|
543
|
-
// -------------------------------------------------------------------------
|
|
544
|
-
// PROACTIVE COMPRESSION & USER CHECK-IN
|
|
545
|
-
// -------------------------------------------------------------------------
|
|
546
|
-
// 1. Check if we need to compress mid-loop to avoid context window crashes
|
|
547
|
-
if (this.sessionManager && this.tarsConfig) {
|
|
548
|
-
const stats = this.sessionManager.getStats();
|
|
549
|
-
const threshold = this.tarsConfig.compressionThreshold || 0.8;
|
|
550
|
-
const limit = this.tarsConfig.contextWindowTokens || 128000;
|
|
551
|
-
if (stats && stats.lastInputTokens > limit * threshold) {
|
|
552
|
-
logger.info(`[GeminiEngine] Mid-loop compression triggered (${stats.lastInputTokens}/${limit} tokens)`);
|
|
553
|
-
try {
|
|
554
|
-
const didCompress = await this.compressSession();
|
|
555
|
-
if (didCompress) {
|
|
556
|
-
await onEvent({
|
|
557
|
-
type: 'text',
|
|
558
|
-
role: 'assistant',
|
|
559
|
-
content: '\n\n✨ *Mid-task memory compacted to optimally save context space.*',
|
|
560
|
-
sessionId: sid
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
catch (e) {
|
|
565
|
-
logger.warn(`[GeminiEngine] Mid-loop compression failed: ${e.message}`);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
// 2. Max turns safety (handled by loop condition, but we break here if needed)
|
|
570
|
-
}
|
|
571
|
-
// If the loop finished without producing any content, notify the user
|
|
572
|
-
if (!hasRealContent) {
|
|
573
|
-
let fallbackMsg = '\n\n⚠️ **Model Interaction Issue**: The Gemini model failed to produce a valid text response.';
|
|
574
|
-
if (loopDetected) {
|
|
575
|
-
fallbackMsg +=
|
|
576
|
-
' A repetitive output loop was detected and terminated. This can sometimes happen with complex prompts or transient API glitches.';
|
|
577
|
-
}
|
|
578
|
-
fallbackMsg += '\n\nPlease try rephrasing your request or starting a new session.';
|
|
579
|
-
await onEvent({
|
|
580
|
-
type: 'text',
|
|
581
|
-
role: 'assistant',
|
|
582
|
-
content: fallbackMsg,
|
|
583
|
-
sessionId: sid
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
// Always emit final done event when exiting the loop
|
|
587
|
-
await onEvent({
|
|
588
|
-
type: 'done',
|
|
589
|
-
usageStats: accumulatedInputTokens > 0 || accumulatedOutputTokens > 0
|
|
590
|
-
? {
|
|
591
|
-
inputTokens: accumulatedInputTokens,
|
|
592
|
-
outputTokens: accumulatedOutputTokens,
|
|
593
|
-
cachedTokens: accumulatedCachedTokens
|
|
594
|
-
}
|
|
595
|
-
: finalUsageStats
|
|
596
|
-
? {
|
|
597
|
-
inputTokens: finalUsageStats.promptTokenCount || 0,
|
|
598
|
-
outputTokens: finalUsageStats.candidatesTokenCount || 0,
|
|
599
|
-
cachedTokens: finalUsageStats.cachedContentTokenCount || 0
|
|
600
|
-
}
|
|
601
|
-
: undefined,
|
|
602
|
-
sessionId: sid
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
catch (error) {
|
|
606
|
-
const errorMsg = error.message ||
|
|
607
|
-
(typeof error === 'object' ? JSON.stringify(error) : String(error));
|
|
608
|
-
logger.error(`❌ Gemini Engine run error: ${errorMsg}`);
|
|
609
|
-
await onEvent({ type: 'error', error: errorMsg });
|
|
610
|
-
throw error;
|
|
611
|
-
}
|
|
612
|
-
finally {
|
|
613
|
-
process.env.HOME = savedHome;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Synchronous-style run for background tasks.
|
|
618
|
-
*/
|
|
619
|
-
async runSync(prompt, sessionId) {
|
|
620
|
-
let fullContent = '';
|
|
621
|
-
await this.run(prompt, (event) => {
|
|
622
|
-
if (event.content && event.role === 'assistant') {
|
|
623
|
-
fullContent += event.content;
|
|
624
|
-
}
|
|
625
|
-
}, sessionId);
|
|
626
|
-
return fullContent;
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* Rebuilds the response array for Gemini ensuring 1:1 parity with function calls.
|
|
630
|
-
*/
|
|
631
|
-
static buildToolResponseParts(toolRequests, completedCalls, blockedResponses, sensitiveCalls, isLocalInference = false) {
|
|
632
|
-
return toolRequests
|
|
633
|
-
.map((req) => {
|
|
634
|
-
const callId = req.callId;
|
|
635
|
-
if (blockedResponses.has(callId)) {
|
|
636
|
-
return {
|
|
637
|
-
functionResponse: {
|
|
638
|
-
name: req.name,
|
|
639
|
-
response: { error: blockedResponses.get(callId) }
|
|
640
|
-
}
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
const completedCall = completedCalls.find((c) => (c.request?.callId || c.callId) === callId);
|
|
644
|
-
if (!completedCall)
|
|
645
|
-
return null;
|
|
646
|
-
const part = completedCall.response?.responseParts?.find((p) => 'functionResponse' in p);
|
|
647
|
-
if (part?.functionResponse?.response) {
|
|
648
|
-
let scrubbedResponse = DLPService.scrubDeep(part.functionResponse.response);
|
|
649
|
-
if (sensitiveCalls.has(callId) &&
|
|
650
|
-
typeof scrubbedResponse === 'object' &&
|
|
651
|
-
scrubbedResponse !== null) {
|
|
652
|
-
if (scrubbedResponse.content &&
|
|
653
|
-
typeof scrubbedResponse.content === 'string') {
|
|
654
|
-
scrubbedResponse.content = DLPService.scrubEnvContent(scrubbedResponse.content);
|
|
655
|
-
}
|
|
656
|
-
if (scrubbedResponse.stdout &&
|
|
657
|
-
typeof scrubbedResponse.stdout === 'string') {
|
|
658
|
-
scrubbedResponse.stdout = DLPService.scrubEnvContent(scrubbedResponse.stdout);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
part.functionResponse.response = scrubbedResponse;
|
|
662
|
-
}
|
|
663
|
-
// Inject the original callId so LlamaCppGenerator can map it to tool_call_id.
|
|
664
|
-
// We ONLY do this for local inference as the Cloud Gemini API rejects unknown fields.
|
|
665
|
-
if (isLocalInference) {
|
|
666
|
-
part.id = callId;
|
|
667
|
-
}
|
|
668
|
-
return part;
|
|
669
|
-
})
|
|
670
|
-
.filter(Boolean);
|
|
671
|
-
}
|
|
672
|
-
/**
|
|
673
|
-
* Proactively compress the session history to reclaim context window space.
|
|
674
|
-
* Delegates to Gemini Core's built-in compression.
|
|
675
|
-
*/
|
|
676
|
-
async compressSession(force = false) {
|
|
677
|
-
if (!this.initialized || !this.client)
|
|
678
|
-
return false;
|
|
679
|
-
const sid = this.currentSessionId || 'unknown';
|
|
680
|
-
logger.info(`🗜️ Triggering session compression (force=${force})...`);
|
|
681
|
-
try {
|
|
682
|
-
if (this.tarsConfig.inferenceBackend === 'llamacpp') {
|
|
683
|
-
const history = this.client.getHistory();
|
|
684
|
-
// We keep the most recent ~60% and ensure the boundary lands on a 'user' role
|
|
685
|
-
// to maintain proper turn alternation (no orphaned tool responses).
|
|
686
|
-
if (history && history.length > 20) {
|
|
687
|
-
const keepCount = Math.ceil(history.length * 0.6);
|
|
688
|
-
let cutIndex = history.length - keepCount;
|
|
689
|
-
// Walk forward to find a 'user' role entry for clean boundary
|
|
690
|
-
while (cutIndex < history.length && history[cutIndex]?.role !== 'user') {
|
|
691
|
-
cutIndex++;
|
|
692
|
-
}
|
|
693
|
-
if (cutIndex < history.length) {
|
|
694
|
-
const historyToCompress = history.slice(0, cutIndex);
|
|
695
|
-
const tail = history.slice(cutIndex);
|
|
696
|
-
logger.info(`🗜️ Local inference compaction: Summarizing oldest ${historyToCompress.length} turns...`);
|
|
697
|
-
// Use the local generator non-streamed to summarize the truncated chunk
|
|
698
|
-
const generator = this.coreConfig.contentGenerator;
|
|
699
|
-
const hasPreviousSnapshot = historyToCompress.some((c) => c.parts?.some((p) => p.text?.includes('<state_snapshot>')));
|
|
700
|
-
const anchorInstruction = hasPreviousSnapshot
|
|
701
|
-
? 'A previous <state_snapshot> exists in the history. You MUST integrate all still-relevant information from that snapshot into the new one, updating it with the more recent events.'
|
|
702
|
-
: 'Generate a new <state_snapshot> based on the provided history.';
|
|
703
|
-
const summaryPrompt = `${anchorInstruction}\nExtract all important constraints, configs, details and tool results from this chunk of history. Format your response cleanly.`;
|
|
704
|
-
let summaryContent = '';
|
|
705
|
-
try {
|
|
706
|
-
const response = await generator.generateContent({
|
|
707
|
-
model: this.tarsConfig.geminiModel,
|
|
708
|
-
contents: [
|
|
709
|
-
...historyToCompress,
|
|
710
|
-
{ role: 'user', parts: [{ text: summaryPrompt }] }
|
|
711
|
-
]
|
|
712
|
-
}, sid);
|
|
713
|
-
summaryContent =
|
|
714
|
-
response?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
logger.warn(`Semantic compression inference failed: ${err.message}`);
|
|
718
|
-
}
|
|
719
|
-
if (!summaryContent) {
|
|
720
|
-
summaryContent =
|
|
721
|
-
'*(Summary generation failed, falling back to raw truncation)*';
|
|
722
|
-
}
|
|
723
|
-
const newHistory = [
|
|
724
|
-
{
|
|
725
|
-
role: 'user',
|
|
726
|
-
parts: [
|
|
727
|
-
{
|
|
728
|
-
text: `<state_snapshot>\n${summaryContent.trim()}\n</state_snapshot>`
|
|
729
|
-
}
|
|
730
|
-
]
|
|
731
|
-
},
|
|
732
|
-
{
|
|
733
|
-
role: 'model',
|
|
734
|
-
parts: [
|
|
735
|
-
{ text: 'Got it. I will keep this historical context in mind.' }
|
|
736
|
-
]
|
|
737
|
-
},
|
|
738
|
-
...tail
|
|
739
|
-
];
|
|
740
|
-
this.client.setHistory(newHistory);
|
|
741
|
-
logger.info(`🗜️ Local inference context compacted: retained tail of ${tail.length} turns + snapshot.`);
|
|
742
|
-
return true;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return false;
|
|
746
|
-
}
|
|
747
|
-
const result = await this.client.tryCompressChat(sid, force);
|
|
748
|
-
logger.info(`🗜️ Compression result: status=${result.compressionStatus}, ` +
|
|
749
|
-
`${result.originalTokenCount} → ${result.newTokenCount} tokens`);
|
|
750
|
-
return String(result.compressionStatus) === 'COMPRESSED';
|
|
751
|
-
}
|
|
752
|
-
catch (e) {
|
|
753
|
-
logger.warn(`⚠️ Compression failed: ${e.message}`);
|
|
754
|
-
return false;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
/**
|
|
758
|
-
* Refreshes the system instruction in-place without destroying the session.
|
|
759
|
-
* Used after memory mutations so the model sees updated facts.
|
|
760
|
-
*/
|
|
761
|
-
refreshSystemInstruction() {
|
|
762
|
-
if (!this.initialized || !this.client)
|
|
763
|
-
return;
|
|
764
|
-
this.client.updateSystemInstruction();
|
|
765
|
-
logger.debug('🔄 System instruction refreshed in-place');
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Maps native core events to Tars-compatible event format.
|
|
769
|
-
*/
|
|
770
|
-
normalizeEvent(event, sessionId) {
|
|
771
|
-
switch (event.type) {
|
|
772
|
-
case GeminiEventType.Content:
|
|
773
|
-
return {
|
|
774
|
-
type: 'text',
|
|
775
|
-
role: 'assistant',
|
|
776
|
-
content: event.value,
|
|
777
|
-
sessionId
|
|
778
|
-
};
|
|
779
|
-
case GeminiEventType.Thought:
|
|
780
|
-
// ThoughtSummary has subject and description
|
|
781
|
-
const thoughtText = event.value.subject
|
|
782
|
-
? `**${event.value.subject}** ${event.value.description}`
|
|
783
|
-
: event.value.description;
|
|
784
|
-
return {
|
|
785
|
-
type: 'thought',
|
|
786
|
-
content: DLPService.scrub(thoughtText),
|
|
787
|
-
sessionId
|
|
788
|
-
};
|
|
789
|
-
case GeminiEventType.ToolCallRequest:
|
|
790
|
-
return {
|
|
791
|
-
type: 'tool_call',
|
|
792
|
-
toolName: event.value.name,
|
|
793
|
-
toolArgs: DLPService.scrubDeep(event.value.args),
|
|
794
|
-
callId: event.value.callId,
|
|
795
|
-
sessionId
|
|
796
|
-
};
|
|
797
|
-
case GeminiEventType.ToolCallResponse:
|
|
798
|
-
// resultDisplay can be string | FileDiff | AnsiOutput | TodoList
|
|
799
|
-
// Support both ToolCallResponseInfo and CompletedToolCall payloads
|
|
800
|
-
const val = event.value;
|
|
801
|
-
const callInfo = val.response ? val.response : val;
|
|
802
|
-
const display = callInfo.resultDisplay;
|
|
803
|
-
let content = '';
|
|
804
|
-
if (typeof display === 'string') {
|
|
805
|
-
content = display;
|
|
806
|
-
}
|
|
807
|
-
else if (display) {
|
|
808
|
-
content = JSON.stringify(display);
|
|
809
|
-
}
|
|
810
|
-
else if (callInfo.error) {
|
|
811
|
-
content = callInfo.error.message;
|
|
812
|
-
}
|
|
813
|
-
return {
|
|
814
|
-
type: 'tool_response',
|
|
815
|
-
toolName: val.request?.callId || callInfo.callId,
|
|
816
|
-
content: DLPService.scrub(content),
|
|
817
|
-
sessionId
|
|
818
|
-
};
|
|
819
|
-
case GeminiEventType.Finished:
|
|
820
|
-
return {
|
|
821
|
-
type: 'done',
|
|
822
|
-
usageStats: event.value.usageMetadata
|
|
823
|
-
? {
|
|
824
|
-
inputTokens: event.value.usageMetadata.promptTokenCount || 0,
|
|
825
|
-
outputTokens: event.value.usageMetadata.candidatesTokenCount || 0,
|
|
826
|
-
cachedTokens: event.value.usageMetadata.cachedContentTokenCount || 0
|
|
827
|
-
}
|
|
828
|
-
: undefined,
|
|
829
|
-
sessionId
|
|
830
|
-
};
|
|
831
|
-
case GeminiEventType.Error:
|
|
832
|
-
let errorDetails = '';
|
|
833
|
-
if (event.value instanceof Error) {
|
|
834
|
-
errorDetails = event.value.message;
|
|
835
|
-
}
|
|
836
|
-
else if (typeof event.value === 'object' && event.value !== null) {
|
|
837
|
-
// Try to extract nested error message if it exists (common in Google API errors)
|
|
838
|
-
const val = event.value;
|
|
839
|
-
errorDetails = val.message || val.error?.message || JSON.stringify(event.value);
|
|
840
|
-
}
|
|
841
|
-
else {
|
|
842
|
-
errorDetails = String(event.value);
|
|
843
|
-
}
|
|
844
|
-
return {
|
|
845
|
-
type: 'error',
|
|
846
|
-
error: errorDetails,
|
|
847
|
-
sessionId
|
|
848
|
-
};
|
|
849
|
-
case GeminiEventType.LoopDetected:
|
|
850
|
-
return {
|
|
851
|
-
type: 'loop_detected',
|
|
852
|
-
sessionId
|
|
853
|
-
};
|
|
854
|
-
case GeminiEventType.ChatCompressed: {
|
|
855
|
-
const info = event.value;
|
|
856
|
-
if (info && info.compressionStatus === CompressionStatus.COMPRESSED) {
|
|
857
|
-
return {
|
|
858
|
-
type: 'compressed',
|
|
859
|
-
content: `🗜️ Session compressed: ${info.originalTokenCount.toLocaleString()} → ${info.newTokenCount.toLocaleString()} tokens`,
|
|
860
|
-
sessionId
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
return null;
|
|
864
|
-
}
|
|
865
|
-
case GeminiEventType.ContextWindowWillOverflow:
|
|
866
|
-
logger.warn(`⚠️ Context window near overflow: ${event.value.estimatedRequestTokenCount} tokens, ${event.value.remainingTokenCount} remaining`);
|
|
867
|
-
return {
|
|
868
|
-
type: 'context_warning',
|
|
869
|
-
content: `⚠️ Context window near capacity (${event.value.remainingTokenCount.toLocaleString()} tokens remaining)`,
|
|
870
|
-
sessionId
|
|
871
|
-
};
|
|
872
|
-
case GeminiEventType.MaxSessionTurns:
|
|
873
|
-
return {
|
|
874
|
-
type: 'max_turns',
|
|
875
|
-
content: '⚠️ Maximum session turns reached. Consider compressing the session.',
|
|
876
|
-
sessionId
|
|
877
|
-
};
|
|
878
|
-
default:
|
|
879
|
-
return null;
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
/**
|
|
883
|
-
* Attempts to find and load session history from the Core's history directory.
|
|
884
|
-
*/
|
|
885
|
-
async loadResumedSessionData(sessionId) {
|
|
886
|
-
try {
|
|
887
|
-
// Core history is usually in ~/.gemini/tmp/<hash>/chats/
|
|
888
|
-
// But we isolated HOME to ~/.tars, so it's in ~/.tars/.gemini/...
|
|
889
|
-
const projectRoot = this.tarsConfig.homeDir;
|
|
890
|
-
const geminiDir = path.join(this.tarsConfig.homeDir, '.gemini');
|
|
891
|
-
const tmpDir = path.join(geminiDir, 'tmp');
|
|
892
|
-
if (!fs.existsSync(tmpDir))
|
|
893
|
-
return null;
|
|
894
|
-
// 1. Try to find the exact project identifier from projects.json
|
|
895
|
-
let projectIdentifier = null;
|
|
896
|
-
const registryPath = path.join(geminiDir, 'projects.json');
|
|
897
|
-
if (fs.existsSync(registryPath)) {
|
|
898
|
-
try {
|
|
899
|
-
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
|
|
900
|
-
projectIdentifier = registry.projects[projectRoot] || null;
|
|
901
|
-
}
|
|
902
|
-
catch (e) {
|
|
903
|
-
logger.warn(`⚠️ Failed to read projects.json: ${e}`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
// 2. Fallback: MD5 hash (used in some versions)
|
|
907
|
-
if (!projectIdentifier) {
|
|
908
|
-
const crypto = await import('node:crypto');
|
|
909
|
-
projectIdentifier = crypto.createHash('md5').update(projectRoot).digest('hex');
|
|
910
|
-
}
|
|
911
|
-
// 3. Search for the session file in candidate directories
|
|
912
|
-
// We search projectIdentifier first, then scan all if not found
|
|
913
|
-
const searchDirs = [projectIdentifier];
|
|
914
|
-
try {
|
|
915
|
-
const allDirs = fs.readdirSync(tmpDir);
|
|
916
|
-
for (const d of allDirs) {
|
|
917
|
-
if (d !== projectIdentifier)
|
|
918
|
-
searchDirs.push(d);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
catch (e) { }
|
|
922
|
-
const shortId = sessionId.slice(0, 8);
|
|
923
|
-
for (const dir of searchDirs) {
|
|
924
|
-
if (!dir)
|
|
925
|
-
continue;
|
|
926
|
-
const chatsDir = path.join(tmpDir, dir, 'chats');
|
|
927
|
-
if (!fs.existsSync(chatsDir))
|
|
928
|
-
continue;
|
|
929
|
-
const files = fs.readdirSync(chatsDir);
|
|
930
|
-
const sessionFile = files.find((f) => f.includes(`-${shortId}.json`));
|
|
931
|
-
if (sessionFile) {
|
|
932
|
-
const filePath = path.join(chatsDir, sessionFile);
|
|
933
|
-
const content = await loadConversationRecord(filePath);
|
|
934
|
-
logger.info(`📂 Resumed session from exact match: ${sessionFile}`);
|
|
935
|
-
return {
|
|
936
|
-
conversation: content,
|
|
937
|
-
filePath
|
|
938
|
-
};
|
|
939
|
-
}
|
|
940
|
-
// Fallback: If no exact session ID match, use the most recently
|
|
941
|
-
// modified chat file. This prevents a blank cold start when the
|
|
942
|
-
// Tars session ID has drifted from Core's internal session ID.
|
|
943
|
-
// We also check for .jsonl files used in newer Core versions.
|
|
944
|
-
const jsonFiles = files.filter((f) => f.endsWith('.json') || f.endsWith('.jsonl'));
|
|
945
|
-
if (jsonFiles.length > 0) {
|
|
946
|
-
const sorted = jsonFiles
|
|
947
|
-
.map((f) => ({
|
|
948
|
-
name: f,
|
|
949
|
-
mtime: fs.statSync(path.join(chatsDir, f)).mtimeMs
|
|
950
|
-
}))
|
|
951
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
952
|
-
const latestFile = sorted[0].name;
|
|
953
|
-
logger.warn(`⚠️ No exact session match for ${shortId}. Falling back to latest: ${latestFile}`);
|
|
954
|
-
const filePath = path.join(chatsDir, latestFile);
|
|
955
|
-
const content = await loadConversationRecord(filePath);
|
|
956
|
-
return {
|
|
957
|
-
conversation: content,
|
|
958
|
-
filePath
|
|
959
|
-
};
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
return null;
|
|
963
|
-
}
|
|
964
|
-
catch (e) {
|
|
965
|
-
logger.warn(`⚠️ Failed to load resumed session data: ${e}`);
|
|
966
|
-
return null;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
/**
|
|
970
|
-
* Converts a ConversationRecord from Core to Gemini API Content[] history.
|
|
971
|
-
*/
|
|
972
|
-
convertRecordToHistory(conversation) {
|
|
973
|
-
const history = [];
|
|
974
|
-
if (!conversation || !conversation.messages)
|
|
975
|
-
return history;
|
|
976
|
-
for (const msg of conversation.messages) {
|
|
977
|
-
if (msg.type === 'user') {
|
|
978
|
-
let parts = [];
|
|
979
|
-
if (typeof msg.content === 'string') {
|
|
980
|
-
parts = [{ text: msg.content }];
|
|
981
|
-
}
|
|
982
|
-
else if (Array.isArray(msg.content)) {
|
|
983
|
-
parts = msg.content;
|
|
984
|
-
}
|
|
985
|
-
history.push({ role: 'user', parts });
|
|
986
|
-
}
|
|
987
|
-
else if (msg.type === 'gemini') {
|
|
988
|
-
let parts = [];
|
|
989
|
-
if (typeof msg.content === 'string' && msg.content !== '') {
|
|
990
|
-
parts.push({ text: msg.content });
|
|
991
|
-
}
|
|
992
|
-
else if (Array.isArray(msg.content)) {
|
|
993
|
-
parts.push(...msg.content);
|
|
994
|
-
}
|
|
995
|
-
// Add function calls if any
|
|
996
|
-
const functionResponseParts = [];
|
|
997
|
-
if (msg.toolCalls) {
|
|
998
|
-
for (const tc of msg.toolCalls) {
|
|
999
|
-
parts.push({
|
|
1000
|
-
functionCall: {
|
|
1001
|
-
name: tc.name,
|
|
1002
|
-
args: tc.args
|
|
1003
|
-
}
|
|
1004
|
-
});
|
|
1005
|
-
// If the tool call has a result, we need to add a function response
|
|
1006
|
-
if (tc.status === 'done' || tc.result) {
|
|
1007
|
-
let responseObj = tc.result;
|
|
1008
|
-
if (typeof responseObj === 'string') {
|
|
1009
|
-
try {
|
|
1010
|
-
responseObj = JSON.parse(responseObj);
|
|
1011
|
-
}
|
|
1012
|
-
catch (e) {
|
|
1013
|
-
responseObj = { result: responseObj };
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
else if (responseObj === null || responseObj === undefined) {
|
|
1017
|
-
responseObj = { result: 'Success' };
|
|
1018
|
-
}
|
|
1019
|
-
functionResponseParts.push({
|
|
1020
|
-
functionResponse: {
|
|
1021
|
-
name: tc.name,
|
|
1022
|
-
response: responseObj
|
|
1023
|
-
}
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
history.push({ role: 'model', parts });
|
|
1029
|
-
// If we generated function response parts, they belong to the NEXT turn as 'user'
|
|
1030
|
-
if (functionResponseParts.length > 0) {
|
|
1031
|
-
history.push({ role: 'user', parts: functionResponseParts });
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
return history;
|
|
1036
|
-
}
|
|
1037
|
-
/**
|
|
1038
|
-
* Applies runtime overrides to the Gemini client to ensure smooth operation
|
|
1039
|
-
* in specific environments (e.g., local inference).
|
|
1040
|
-
*/
|
|
1041
|
-
applyClientOverrides(client) {
|
|
1042
|
-
if (this.tarsConfig.inferenceBackend === 'llamacpp') {
|
|
1043
|
-
// The loop detector runs concurrently in the background and causes 400 crashes
|
|
1044
|
-
// for local-only setups that don't have a valid Google API key.
|
|
1045
|
-
const loopService = client.getLoopDetectionService();
|
|
1046
|
-
if (loopService) {
|
|
1047
|
-
logger.debug('🔇 Silencing LoopDetectionService for local inference...');
|
|
1048
|
-
loopService.queryLoopDetectionModel = async () => {
|
|
1049
|
-
logger.debug('Background loop verification skipped (Local Mode).');
|
|
1050
|
-
return null;
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
//# sourceMappingURL=gemini-engine.js.map
|