Skip to content

Commit c44e552

Browse files
committed
org: move utils into own directory and work on agent interface;
Signed-off-by: vsoch <[email protected]>
1 parent b4eca25 commit c44e552

File tree

9 files changed

+487
-23
lines changed

9 files changed

+487
-23
lines changed

fractale/agent/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ class Agent:
22
"""
33
A base for an agent
44
"""
5-
65
# name and description should be on the class
76

7+
def __init__(self):
8+
self.attempts = None
9+
810
def add_arguments(self, subparser):
911
"""
1012
Add arguments for the agent to show up in argparse

fractale/agent/build/agent.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from fractale.agent.base import Agent
22
import fractale.agent.build.prompts as prompts
3+
from fractale.agent.context import get_context
4+
35
import fractale.utils as utils
46
import argparse
57

@@ -18,7 +20,6 @@
1820
# regular expression in case LLM does not follow my instructions!
1921
dockerfile_pattern = r"```(?:dockerfile)?\n(.*?)```"
2022

21-
2223
class BuildAgent(Agent):
2324
"""
2425
Builder agent.
@@ -60,33 +61,52 @@ def add_arguments(self, subparser):
6061
dest="agent_name",
6162
)
6263

63-
def run(self, args, extra, error_message=None, dockerfile=None, attempts=0):
64+
def run(self, context):
6465
"""
65-
Run the agent.
66+
Run the agent.
67+
68+
TODO: get working here
69+
Then think about how to move between steps. We will want to be able to
70+
get initial variables from a plan for redoing steps. OR keep the last
71+
state of running it.
6672
"""
6773
import google.generativeai as genai
6874

75+
# Create or get global context
76+
context = get_context(context)
77+
78+
# Init attempts. Each agent has an internal counter for total attempts
79+
self.attempts = self.attempts or 0
80+
81+
# Map context into needed arguments and validate
82+
environment = context.get('environment', "generic cloud environment")
83+
application = context.get('application', required=True)
84+
85+
# These are optional if we are doing a follow up build
86+
error_message = context.get('error_message')
87+
dockerfile = context.get('dockerfile')
88+
6989
try:
7090
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
7191
except KeyError:
7292
sys.exit("ERROR: GEMINI_API_KEY environment variable not set.")
7393

7494
# This will either generate fresh or rebuild erroneous Dockerfile
75-
dockerfile = self.generate_dockerfile(
76-
args.application, args.environment, error_message=error_message, dockerfile=dockerfile
95+
# We don't return the dockerfile because it is updated in the context
96+
context.dockerfile = self.generate_dockerfile(
97+
context.application, context.environment, error_message=error_message, dockerfile=dockerfile
7798
)
78-
print(Panel(dockerfile, title="[green]Dockerfile[/green]", border_style="green"))
99+
print(Panel(context.dockerfile, title="[green]Dockerfile[/green]", border_style="green"))
79100

80101
# Build it! We might want to only allow a certain number of retries or incremental changes.
81102
return_code, output = self.build(
82-
dockerfile,
83-
image_name=args.container or self.generate_name(args.application),
84-
attempt=attempts,
103+
context.dockerfile,
104+
image_name=context.container or self.generate_name(application),
85105
)
86106
if return_code == 0:
87107
print(
88108
Panel(
89-
f"[bold green]✅ Build complete in {attempts} attempts[/bold green]",
109+
f"[bold green]✅ Build complete in {self.attempts} attempts[/bold green]",
90110
title="Success",
91111
border_style="green",
92112
)
@@ -97,18 +117,19 @@ def run(self, args, extra, error_message=None, dockerfile=None, attempts=0):
97117
"[bold red]❌ Build failed[/bold red]", title="Build Status", border_style="red"
98118
)
99119
)
100-
attempts += 1
120+
self.attempts += 1
101121
print("\n[bold cyan] Requesting Correction from Build Agent[/bold cyan]")
102-
return self.run(
103-
args, extra, error_message=output, dockerfile=dockerfile, attempts=attempts
104-
)
122+
123+
# Update the context with error message
124+
context.error_message = output
125+
return self.run(context)
105126

106127
# Add generation line
107-
dockerfile += "\n# Generated by fractale build agent"
108-
self.print_dockerfile(dockerfile)
109-
if args.outfile:
110-
utils.write_file(dockerfile, args.outfile)
111-
return dockerfile
128+
context.dockerfile += "\n# Generated by fractale build agent"
129+
self.print_dockerfile(context.dockerfile)
130+
if context.outfile:
131+
utils.write_file(dockerfile, context.outfile)
132+
return context.dockerfile
112133

113134
def print_dockerfile(self, dockerfile):
114135
"""
@@ -147,7 +168,7 @@ def generate_name(self, name):
147168
name = name + "c"
148169
return name
149170

150-
def build(self, dockerfile, image_name, attempt):
171+
def build(self, dockerfile, image_name):
151172
"""
152173
Build the Dockerfile! Yolo!
153174
"""
@@ -162,7 +183,7 @@ def build(self, dockerfile, image_name, attempt):
162183
utils.write_file(dockerfile, os.path.join(build_dir, "Dockerfile"))
163184
print(
164185
Panel(
165-
f"Attempt {attempt} to build image: [bold cyan]{image_name}[/bold cyan]",
186+
f"Attempt {self.attempts} to build image: [bold cyan]{image_name}[/bold cyan]",
166187
title="[blue]Docker Build[/blue]",
167188
border_style="blue",
168189
)

fractale/agent/context.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import fractale.utils as utils
2+
import shutil
3+
import os
4+
import re
5+
import tempfile
6+
import collections
7+
8+
def get_context(context):
9+
"""
10+
Get or create the context.
11+
"""
12+
if isinstance(context, Context):
13+
return context
14+
return Context(context)
15+
16+
17+
class Context(collections.UserDict):
18+
"""
19+
A custom dictionary that allows attribute-style access to keys,
20+
and extends the 'get' method with a 'required' argument.
21+
"""
22+
def __init__(self, *args, **kwargs):
23+
super().__init__(*args, **kwargs)
24+
25+
# Testing out this idea - instead of requiring specific inputs/outputs, we are going
26+
# to write to a common context directory, and allow each LLM to discover files and use them
27+
# as needed.
28+
workspace = kwargs.get("workspace")
29+
self.workspace = workspace or tempfile.mkdtemp()
30+
31+
def __getattribute__(self, name):
32+
"""
33+
Intercepts all attribute lookups (including methods/functions)
34+
"""
35+
try:
36+
# Step 1: this would be a normal attribute
37+
attr = object.__getattribute__(self, name)
38+
except AttributeError:
39+
# Then handle lookup of dict key by attribute
40+
return super().__getattribute__(name)
41+
42+
# Step 2: We allow "get" to be called with defaults / required.
43+
if name == 'get':
44+
original_get = attr
45+
46+
def custom_get(key, default=None, required=False):
47+
"""
48+
Wrapper for the standard dict.get() method.
49+
Accepts the custom 'required' argument.
50+
"""
51+
# Load context if needed
52+
self.load(key)
53+
54+
if required:
55+
if key not in self.data:
56+
raise KeyError(f"Key '{key}' is required but missing.")
57+
58+
# If required and found, just return the value
59+
return self.data[key]
60+
else:
61+
# If not required, use the original dict.get behavior
62+
return original_get(key, default)
63+
64+
# Return the wrapper function instead of the original method
65+
return custom_get
66+
67+
# 4. For any other attribute (like keys(), items(), update(), or custom methods)
68+
# return the attribute we found earlier
69+
return attr
70+
71+
# 5. Override __getattr__ to handle attribute-style access to dictionary keys
72+
def __getattr__(self, name):
73+
"""
74+
Allows access to dictionary keys as attributes.
75+
"""
76+
if name in self.data:
77+
return self.data[name]
78+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
79+
80+
def __setattr__(self, name, value):
81+
"""
82+
Allows setting keys via attribute assignment.
83+
"""
84+
# If the attribute name is a reserved name (like 'data'), set it normally
85+
if name in ('data', '_data'):
86+
super().__setattr__(name, value)
87+
88+
# Otherwise, treat it as a dictionary key
89+
else:
90+
self.data[name] = value
91+
92+
def load(self, key):
93+
"""
94+
Load the entire context. We assume that text content has already been added
95+
to the variable context.
96+
"""
97+
context_dir = os.path.join(self.workspace, key)
98+
if not os.path.exists(context_dir):
99+
return
100+
101+
# content must only include one file for now
102+
fullpaths = os.listdir(context_dir)
103+
if not fullpaths:
104+
return
105+
fullpath = os.path.join(context_dir, fullpaths[0])
106+
content = self.read_file(fullpath)
107+
self.data[key] = content
108+
return content
109+
110+
def load_all(self):
111+
"""
112+
Load the entire context. We assume that text content has already been added
113+
to the variable context.
114+
"""
115+
for key in os.listdir(self.workspace):
116+
context_dir = os.path.join(self.workspace, key)
117+
# content must only include one file for now
118+
fullpaths = os.listdir(context_dir)
119+
if not fullpaths:
120+
continue
121+
fullpath = os.path.join(context_dir, fullpaths[0])
122+
self.context[key] = self.read_file(fullpath)
123+
124+
def read_file(self, filename):
125+
"""
126+
Read the full file name
127+
"""
128+
if filename.endswith('json'):
129+
return utils.read_json(filename)
130+
elif re.search("(yaml|yml)$", filename):
131+
return utils.read_yaml(filename)
132+
return utils.read_file(filename)
133+
134+
def save(self, name, content, filename):
135+
"""
136+
Save content to the context. The filename should be a relative path.
137+
Objects will be stored akin to a simple kvs like:
138+
139+
./<context>/
140+
<key>/Dockerfile
141+
142+
Right now we are going to assume that any file that isn't .json/yaml
143+
will be loaded as text. The relative path of the file is meaningful. If
144+
we need extended metadata we can add a metadata.json.
145+
"""
146+
context_dir = os.path.join(self.workspace, name)
147+
if not os.path.exists(context_dir):
148+
os.makedirs(context_dir)
149+
context_file = os.path.join(context_dir, filename)
150+
utils.save_file(content, context_file)
151+
152+
def cleanup(self):
153+
"""
154+
Cleanup the context if not done yet.
155+
156+
To start, let's make the default cleanup and we can reverse when
157+
we move out of development.
158+
"""
159+
if self.context.get('keep') is None:
160+
shutil.rmtree(self.workspace, ignore_errors=True)

0 commit comments

Comments
 (0)