lambda-agent 0.1.0__py3-none-any.whl
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.
- lambda_agent/__init__.py +0 -0
- lambda_agent/agent.py +279 -0
- lambda_agent/cli_setup.py +43 -0
- lambda_agent/config.py +30 -0
- lambda_agent/context.py +140 -0
- lambda_agent/main.py +318 -0
- lambda_agent/scratchpad.py +139 -0
- lambda_agent/spinner.py +50 -0
- lambda_agent/subagent.py +276 -0
- lambda_agent/tools.py +217 -0
- lambda_agent-0.1.0.dist-info/METADATA +118 -0
- lambda_agent-0.1.0.dist-info/RECORD +16 -0
- lambda_agent-0.1.0.dist-info/WHEEL +5 -0
- lambda_agent-0.1.0.dist-info/entry_points.txt +2 -0
- lambda_agent-0.1.0.dist-info/licenses/LICENSE +201 -0
- lambda_agent-0.1.0.dist-info/top_level.txt +1 -0
lambda_agent/__init__.py
ADDED
|
File without changes
|
lambda_agent/agent.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from . import config
|
|
3
|
+
from .tools import TOOL_EXECUTORS, TOOL_FUNCTIONS, get_workspace_summary
|
|
4
|
+
from .context import Transcript, trim_chat_history
|
|
5
|
+
from .spinner import Spinner, console
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich import box
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TokenUsage:
|
|
14
|
+
prompt: int = 0
|
|
15
|
+
completion: int = 0
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def total(self) -> int:
|
|
19
|
+
return self.prompt + self.completion
|
|
20
|
+
|
|
21
|
+
def __add__(self, other: "TokenUsage") -> "TokenUsage":
|
|
22
|
+
return TokenUsage(
|
|
23
|
+
self.prompt + other.prompt, self.completion + other.completion
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from google import genai
|
|
29
|
+
from google.genai import types
|
|
30
|
+
except ImportError:
|
|
31
|
+
print(
|
|
32
|
+
"Warning: google-genai package is not installed. Please `pip install google-genai`."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Agent:
|
|
37
|
+
def __init__(self):
|
|
38
|
+
# Configure Gemini API client
|
|
39
|
+
self.client = genai.Client(api_key=config.API_KEY)
|
|
40
|
+
self.model_name = config.MODEL_NAME
|
|
41
|
+
|
|
42
|
+
self.workspace_context = get_workspace_summary()
|
|
43
|
+
self.is_first_message = True
|
|
44
|
+
|
|
45
|
+
# Cumulative token usage for this session
|
|
46
|
+
self.token_usage: TokenUsage = TokenUsage()
|
|
47
|
+
|
|
48
|
+
# Full transcript — append-only log that is never truncated
|
|
49
|
+
self.transcript = Transcript()
|
|
50
|
+
|
|
51
|
+
self.system_instruction = (
|
|
52
|
+
"You are Lambda, a minimal and highly efficient AI coding agent. "
|
|
53
|
+
"Your primary goal is to help the user by writing code, executing commands, "
|
|
54
|
+
"and managing files. You have access to tools that let you read files, "
|
|
55
|
+
"write files, run shell commands, and ask the user questions. "
|
|
56
|
+
"Whenever the user asks you to do something that requires these tools, "
|
|
57
|
+
"you should use them autonomously. "
|
|
58
|
+
"CRITICAL: Do not guess the user's intent. Guessing is bad. "
|
|
59
|
+
"If there is any confusion or ambiguity, you MUST use the ask_user tool "
|
|
60
|
+
"to clarify the job with the human. You can ask multiple questions. "
|
|
61
|
+
"Be concise and professional.\n\n"
|
|
62
|
+
"## Error Handling\n"
|
|
63
|
+
"If you encounter an error when executing a tool or command, DO NOT immediately guess "
|
|
64
|
+
"and try to fix it in a fast loop. First, take a moment to fully understand the error. "
|
|
65
|
+
"Investigate the specific context (e.g., read the file, check the directory) to figure "
|
|
66
|
+
"out why it failed before trying a new command.\n\n"
|
|
67
|
+
"## Scratchpad\n"
|
|
68
|
+
"You have a persistent scratchpad file (.agent/scratchpad.md) available "
|
|
69
|
+
"in the working directory. Use it for complex or multi-step tasks:\n"
|
|
70
|
+
"1. **Planning**: Before starting a large task, use write_scratchpad to "
|
|
71
|
+
"outline your plan with sections like '## Plan', '## Implementation Steps', "
|
|
72
|
+
"'## Open Questions'.\n"
|
|
73
|
+
"2. **Progress tracking**: As you complete steps, use update_scratchpad to "
|
|
74
|
+
"log your progress under a '## Progress' section.\n"
|
|
75
|
+
"3. **Context persistence**: If a task spans many turns, read_scratchpad "
|
|
76
|
+
"at the start of each turn to recall your plan.\n"
|
|
77
|
+
"4. **Cleanup**: Use clear_scratchpad when a task is fully complete.\n"
|
|
78
|
+
"The scratchpad is stored in a hidden .agent/ directory — it is for your "
|
|
79
|
+
"internal use only and is not shown to the user.\n\n"
|
|
80
|
+
"## Sub-Agents\n"
|
|
81
|
+
"You can spawn lightweight sub-agents using dispatch_subagent to perform "
|
|
82
|
+
"independent, parallelizable work. Sub-agents run in separate threads "
|
|
83
|
+
"with their own Gemini sessions and return short result summaries.\n"
|
|
84
|
+
"WHEN TO USE:\n"
|
|
85
|
+
"- Parallel research: reading multiple files, searching for patterns, "
|
|
86
|
+
"analyzing independent parts of the codebase simultaneously.\n"
|
|
87
|
+
"- Delegating small, independent file edits or module updates in parallel.\n"
|
|
88
|
+
"- Running investigative commands in parallel.\n"
|
|
89
|
+
"- Any task where two or more pieces of work don't depend on each other.\n"
|
|
90
|
+
"WHEN NOT TO USE:\n"
|
|
91
|
+
"- Sequential tasks where step 2 depends on step 1's output.\n"
|
|
92
|
+
"- Tasks that require writing to the same file (risk of conflicts).\n"
|
|
93
|
+
"- Simple tasks that you can do faster yourself with a single tool call.\n"
|
|
94
|
+
"HOW TO USE:\n"
|
|
95
|
+
"- Call dispatch_subagent with a clear, self-contained task description.\n"
|
|
96
|
+
"- Provide minimal context (the sub-agent has NO access to your chat history).\n"
|
|
97
|
+
"- You can call dispatch_subagent multiple times in the same turn — they "
|
|
98
|
+
"will execute in parallel.\n"
|
|
99
|
+
"- Each sub-agent returns a concise summary. Use it to inform your next steps."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Initialize the chat session with the built tools and system instructions
|
|
103
|
+
self.chat_session = self.client.chats.create(
|
|
104
|
+
model=self.model_name,
|
|
105
|
+
config=types.GenerateContentConfig(
|
|
106
|
+
system_instruction=self.system_instruction,
|
|
107
|
+
tools=TOOL_FUNCTIONS,
|
|
108
|
+
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
|
109
|
+
disable=True
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def switch_model(self, new_model: str) -> str:
|
|
115
|
+
"""Switch to a different model mid-session. Returns confirmation message."""
|
|
116
|
+
old_model = self.model_name
|
|
117
|
+
self.model_name = new_model
|
|
118
|
+
|
|
119
|
+
# Re-create the chat session with the new model
|
|
120
|
+
self.chat_session = self.client.chats.create(
|
|
121
|
+
model=self.model_name,
|
|
122
|
+
config=types.GenerateContentConfig(
|
|
123
|
+
system_instruction=self.system_instruction,
|
|
124
|
+
tools=TOOL_FUNCTIONS,
|
|
125
|
+
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
|
126
|
+
disable=True
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
self.is_first_message = True
|
|
131
|
+
return f"Switched model from [cyan]{old_model}[/cyan] → [bold cyan]{new_model}[/bold cyan]"
|
|
132
|
+
|
|
133
|
+
def _accumulate(self, response) -> TokenUsage:
|
|
134
|
+
"""Extract token counts from a response and add them to the session total."""
|
|
135
|
+
usage = getattr(response, "usage_metadata", None)
|
|
136
|
+
if usage is None:
|
|
137
|
+
return TokenUsage()
|
|
138
|
+
delta = TokenUsage(
|
|
139
|
+
prompt=getattr(usage, "prompt_token_count", 0) or 0,
|
|
140
|
+
completion=getattr(usage, "candidates_token_count", 0) or 0,
|
|
141
|
+
)
|
|
142
|
+
self.token_usage = self.token_usage + delta
|
|
143
|
+
return delta
|
|
144
|
+
|
|
145
|
+
def chat(self, user_input: str) -> tuple[str, TokenUsage]:
|
|
146
|
+
"""
|
|
147
|
+
Takes user input, sends it to Gemini, and runs a manual loop observing ToolCalls.
|
|
148
|
+
Returns (response_text, turn_token_usage).
|
|
149
|
+
"""
|
|
150
|
+
if self.is_first_message:
|
|
151
|
+
payload = (
|
|
152
|
+
"--- WORKSPACE CONTEXT ---\n"
|
|
153
|
+
f"{self.workspace_context}\n"
|
|
154
|
+
"-------------------------\n\n"
|
|
155
|
+
f"User Request: {user_input}"
|
|
156
|
+
)
|
|
157
|
+
self.is_first_message = False
|
|
158
|
+
else:
|
|
159
|
+
payload = user_input
|
|
160
|
+
|
|
161
|
+
# Track tokens for this turn
|
|
162
|
+
turn_usage = TokenUsage()
|
|
163
|
+
|
|
164
|
+
# Log the user message to the full transcript
|
|
165
|
+
self.transcript.log("user", user_input)
|
|
166
|
+
|
|
167
|
+
# Send the initial user message
|
|
168
|
+
with Spinner():
|
|
169
|
+
response = self.chat_session.send_message(payload)
|
|
170
|
+
turn_usage = turn_usage + self._accumulate(response)
|
|
171
|
+
|
|
172
|
+
max_tool_iterations = 10
|
|
173
|
+
iterations = 0
|
|
174
|
+
|
|
175
|
+
# The loop will continue as long as Gemini decides to call tools
|
|
176
|
+
while True:
|
|
177
|
+
iterations += 1
|
|
178
|
+
if iterations > max_tool_iterations:
|
|
179
|
+
error_msg = f"Error: Maximum tool call limit ({max_tool_iterations}) reached to prevent infinite loops."
|
|
180
|
+
self.transcript.log("assistant", error_msg)
|
|
181
|
+
return error_msg, turn_usage
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# 1. Check if the model returned a function_call
|
|
185
|
+
tool_calls = response.function_calls if response.function_calls else []
|
|
186
|
+
|
|
187
|
+
# 2. If it did, act on each function call
|
|
188
|
+
if tool_calls:
|
|
189
|
+
tool_responses = []
|
|
190
|
+
|
|
191
|
+
for function_call in tool_calls:
|
|
192
|
+
function_name = function_call.name
|
|
193
|
+
|
|
194
|
+
# Convert protobuf args to dict if possible
|
|
195
|
+
arguments = function_call.args
|
|
196
|
+
if hasattr(arguments, "items"):
|
|
197
|
+
arguments = {key: value for key, value in arguments.items()}
|
|
198
|
+
elif not isinstance(arguments, dict):
|
|
199
|
+
arguments = dict(arguments) if arguments else {}
|
|
200
|
+
# Pretty-print the tool call with rich
|
|
201
|
+
# Hide scratchpad operations from the user
|
|
202
|
+
_HIDDEN_TOOLS = {
|
|
203
|
+
"read_scratchpad",
|
|
204
|
+
"write_scratchpad",
|
|
205
|
+
"update_scratchpad",
|
|
206
|
+
"clear_scratchpad",
|
|
207
|
+
}
|
|
208
|
+
if function_name not in _HIDDEN_TOOLS:
|
|
209
|
+
# Sub-agent dispatches get a distinct green style
|
|
210
|
+
if function_name == "dispatch_subagent":
|
|
211
|
+
# The subagent module handles its own display,
|
|
212
|
+
# so we only show a lightweight header here.
|
|
213
|
+
pass
|
|
214
|
+
else:
|
|
215
|
+
tool_label = Text.assemble(
|
|
216
|
+
(" ⚙ TOOL ", "bold black on magenta"),
|
|
217
|
+
(f" {function_name}", "bold magenta"),
|
|
218
|
+
)
|
|
219
|
+
args_str = ", ".join(
|
|
220
|
+
f"[dim]{k}[/dim]=[yellow]{repr(v)}[/yellow]"
|
|
221
|
+
for k, v in arguments.items()
|
|
222
|
+
)
|
|
223
|
+
console.print()
|
|
224
|
+
console.print(tool_label)
|
|
225
|
+
console.print(
|
|
226
|
+
Panel(
|
|
227
|
+
args_str or "[dim](no arguments)[/dim]",
|
|
228
|
+
border_style="magenta",
|
|
229
|
+
box=box.SIMPLE,
|
|
230
|
+
padding=(0, 2),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# 3. Execute the tool locally
|
|
235
|
+
if function_name in TOOL_EXECUTORS:
|
|
236
|
+
function_to_call = TOOL_EXECUTORS[function_name]
|
|
237
|
+
# Call the function dynamically
|
|
238
|
+
tool_result = function_to_call(**arguments)
|
|
239
|
+
else:
|
|
240
|
+
tool_result = f"Error: Tool {function_name} not found."
|
|
241
|
+
|
|
242
|
+
# Log full tool call + result to the untruncated transcript
|
|
243
|
+
self.transcript.log(
|
|
244
|
+
"tool_call",
|
|
245
|
+
function_name,
|
|
246
|
+
meta={"args": {k: str(v) for k, v in arguments.items()}},
|
|
247
|
+
)
|
|
248
|
+
self.transcript.log(
|
|
249
|
+
"tool_result",
|
|
250
|
+
str(tool_result),
|
|
251
|
+
meta={"tool": function_name},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Format the result back into Gemini's expected Response format
|
|
255
|
+
tool_responses.append(
|
|
256
|
+
types.Part.from_function_response(
|
|
257
|
+
name=function_name,
|
|
258
|
+
response={"result": str(tool_result)},
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# 4. Send ALL the tool responses back to the model
|
|
263
|
+
# so it can continue reasoning based on the new information
|
|
264
|
+
with Spinner():
|
|
265
|
+
response = self.chat_session.send_message(tool_responses)
|
|
266
|
+
turn_usage = turn_usage + self._accumulate(response)
|
|
267
|
+
continue # Start the loop over to see if it calls more tools
|
|
268
|
+
else:
|
|
269
|
+
# No more tool calls; the LLM has generated a final text response.
|
|
270
|
+
# Trim older tool responses in the chat history (sliding window)
|
|
271
|
+
try:
|
|
272
|
+
trim_chat_history(self.chat_session._curated_history)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass # Never let trimming crash the agent
|
|
275
|
+
|
|
276
|
+
self.transcript.log("assistant", response.text or "")
|
|
277
|
+
return response.text, turn_usage
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return f"An error occurred in the agent loop: {str(e)}", turn_usage
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_setup() -> tuple[str, str]:
|
|
7
|
+
print("\n" + "=" * 56)
|
|
8
|
+
print(" Welcome to Lambda Agent Setup!")
|
|
9
|
+
print(" This appears to be your first time running Lambda.")
|
|
10
|
+
print("=" * 56 + "\n")
|
|
11
|
+
print("Lambda requires a Gemini API Key to function.")
|
|
12
|
+
print("You can get one for free at: https://aistudio.google.com/app/apikey\n")
|
|
13
|
+
|
|
14
|
+
api_key = ""
|
|
15
|
+
while not api_key:
|
|
16
|
+
api_key = getpass.getpass("Enter your Gemini API Key: ").strip()
|
|
17
|
+
if not api_key:
|
|
18
|
+
print("API Key cannot be empty. Please try again.")
|
|
19
|
+
|
|
20
|
+
default_model = "gemini-3.1-flash-lite-preview"
|
|
21
|
+
model_name = input(f"Enter model name (default: {default_model}): ").strip()
|
|
22
|
+
if not model_name:
|
|
23
|
+
model_name = default_model
|
|
24
|
+
|
|
25
|
+
config_dir = Path.home() / ".config" / "lambda-agent"
|
|
26
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
config_file = config_dir / "config.env"
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Create or update the config file securely
|
|
31
|
+
with open(config_file, "w") as f:
|
|
32
|
+
f.write(f"API_KEY={api_key}\n")
|
|
33
|
+
f.write(f"MODEL_NAME={model_name}\n")
|
|
34
|
+
|
|
35
|
+
# Secure the file (rw for owner only)
|
|
36
|
+
os.chmod(config_file, 0o600)
|
|
37
|
+
|
|
38
|
+
print(f"\n✅ Setup complete! Configuration saved to {config_file}\n")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"\n❌ Error saving configuration: {e}")
|
|
41
|
+
print("Continuing with in-memory configuration for this session.\n")
|
|
42
|
+
|
|
43
|
+
return api_key, model_name
|
lambda_agent/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
# Try loading from global config first
|
|
8
|
+
global_env = Path.home() / ".config" / "lambda-agent" / "config.env"
|
|
9
|
+
if global_env.exists():
|
|
10
|
+
load_dotenv(dotenv_path=global_env)
|
|
11
|
+
|
|
12
|
+
# Allow local .env to override global configs
|
|
13
|
+
load_dotenv(override=True)
|
|
14
|
+
except ImportError:
|
|
15
|
+
print(
|
|
16
|
+
"Warning: python-dotenv not installed. If you are using a .env file, it will not be loaded."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
API_KEY = os.getenv("API_KEY")
|
|
20
|
+
MODEL_NAME = os.getenv("MODEL_NAME", "gemini-3.1-flash-lite-preview")
|
|
21
|
+
|
|
22
|
+
# Models available for /models switching
|
|
23
|
+
AVAILABLE_MODELS = [
|
|
24
|
+
"gemini-3.1-flash-lite-preview",
|
|
25
|
+
"gemini-2.5-flash",
|
|
26
|
+
"gemini-3.1-pro-preview",
|
|
27
|
+
"gemini-2.5-pro-preview-05-06",
|
|
28
|
+
"gemini-2.0-flash",
|
|
29
|
+
"gemini-2.0-flash-lite",
|
|
30
|
+
]
|
lambda_agent/context.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context Management Module
|
|
3
|
+
=========================
|
|
4
|
+
Keeps the agent's context window lean using two complementary strategies:
|
|
5
|
+
|
|
6
|
+
1. **Full Transcript** (``.agent/transcript.jsonl``)
|
|
7
|
+
Append-only log of every tool call and response at full length.
|
|
8
|
+
This is the ground-truth record and is never truncated.
|
|
9
|
+
|
|
10
|
+
2. **Sliding-window trimmer** (``trim_chat_history``)
|
|
11
|
+
After each turn, older tool-call responses in the live chat history
|
|
12
|
+
are truncated so the model's prompt stays within budget.
|
|
13
|
+
|
|
14
|
+
Window tiers (counted from most-recent tool response):
|
|
15
|
+
Tier 1 — last 4 responses → up to 500 chars each
|
|
16
|
+
Tier 2 — next 8 responses → up to 180 chars each
|
|
17
|
+
Tier 3 — anything older → up to 80 chars each
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
AGENT_DIR = ".agent"
|
|
29
|
+
TRANSCRIPT_FILE = os.path.join(AGENT_DIR, "transcript.jsonl")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clip(text: str, max_chars: int) -> str:
|
|
33
|
+
"""Truncate *text* to *max_chars*.
|
|
34
|
+
|
|
35
|
+
If the text is clipped, a notice is appended so the model knows
|
|
36
|
+
the response was shortened.
|
|
37
|
+
"""
|
|
38
|
+
text = str(text)
|
|
39
|
+
if len(text) <= max_chars:
|
|
40
|
+
return text
|
|
41
|
+
return text[:max_chars] + f"\n...[TRUNCATED — original {len(text)} chars]"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Full transcript (append-only log — never truncated)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Transcript:
|
|
50
|
+
"""Append-only JSONL log of every exchange in the session."""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
os.makedirs(AGENT_DIR, exist_ok=True)
|
|
54
|
+
self._path = os.path.abspath(TRANSCRIPT_FILE)
|
|
55
|
+
|
|
56
|
+
def log(self, role: str, content: str, meta: dict | None = None):
|
|
57
|
+
"""Append a single entry to the transcript file.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
role: One of 'user', 'assistant', 'tool_call', 'tool_result'.
|
|
61
|
+
content: The full, untruncated payload.
|
|
62
|
+
meta: Optional dict of extra metadata (tool name, args, etc.).
|
|
63
|
+
"""
|
|
64
|
+
entry: dict = {
|
|
65
|
+
"ts": datetime.now().isoformat(),
|
|
66
|
+
"role": role,
|
|
67
|
+
"content": content,
|
|
68
|
+
}
|
|
69
|
+
if meta:
|
|
70
|
+
entry["meta"] = meta
|
|
71
|
+
try:
|
|
72
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
73
|
+
f.write(json.dumps(entry) + "\n")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass # Transcript logging must never crash the agent
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Sliding-window trimmer
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# Default tier settings
|
|
83
|
+
TIER1_COUNT = 4 # most recent N tool responses
|
|
84
|
+
TIER1_LIMIT = 500 # chars to keep
|
|
85
|
+
|
|
86
|
+
TIER2_COUNT = 8 # next N tool responses
|
|
87
|
+
TIER2_LIMIT = 180
|
|
88
|
+
|
|
89
|
+
TIER3_LIMIT = 80 # everything older
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def trim_chat_history(
|
|
93
|
+
history: list,
|
|
94
|
+
tier1_count: int = TIER1_COUNT,
|
|
95
|
+
tier1_limit: int = TIER1_LIMIT,
|
|
96
|
+
tier2_count: int = TIER2_COUNT,
|
|
97
|
+
tier2_limit: int = TIER2_LIMIT,
|
|
98
|
+
tier3_limit: int = TIER3_LIMIT,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Mutate *history* in-place, truncating function-response payloads.
|
|
101
|
+
|
|
102
|
+
Works directly on the Gemini SDK's ``_curated_history`` list
|
|
103
|
+
(a list of ``Content`` objects whose ``parts`` may contain
|
|
104
|
+
``FunctionResponse`` items).
|
|
105
|
+
|
|
106
|
+
The most recent *tier1_count* function responses are kept at
|
|
107
|
+
*tier1_limit* chars; the next *tier2_count* at *tier2_limit*;
|
|
108
|
+
anything older is clipped to *tier3_limit*.
|
|
109
|
+
"""
|
|
110
|
+
# Collect every (content_index, part_index) that holds a function_response
|
|
111
|
+
fr_locations: list[tuple[int, int]] = []
|
|
112
|
+
|
|
113
|
+
for ci, content in enumerate(history):
|
|
114
|
+
parts = getattr(content, "parts", None) or []
|
|
115
|
+
for pi, part in enumerate(parts):
|
|
116
|
+
fn_resp = getattr(part, "function_response", None)
|
|
117
|
+
if fn_resp is not None:
|
|
118
|
+
fr_locations.append((ci, pi))
|
|
119
|
+
|
|
120
|
+
if not fr_locations:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Walk from most-recent → oldest and apply the right tier limit
|
|
124
|
+
for rank, (ci, pi) in enumerate(reversed(fr_locations)):
|
|
125
|
+
part = history[ci].parts[pi]
|
|
126
|
+
resp = part.function_response.response
|
|
127
|
+
|
|
128
|
+
if resp is None or "result" not in resp:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
original = str(resp["result"])
|
|
132
|
+
|
|
133
|
+
if rank < tier1_count:
|
|
134
|
+
limit = tier1_limit
|
|
135
|
+
elif rank < tier1_count + tier2_count:
|
|
136
|
+
limit = tier2_limit
|
|
137
|
+
else:
|
|
138
|
+
limit = tier3_limit
|
|
139
|
+
|
|
140
|
+
resp["result"] = clip(original, limit)
|