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.
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
+ ]
@@ -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)