fcht-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.
fcht_agent/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ FCHT Agent - First-Class Hermes Tool Agent
3
+ Offline ReAct agent with persistent memory (episodic + semantic)
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+ __author__ = "Joseph Brown & Newton"
8
+ __license__ = "MIT"
9
+
10
+ from fcht_agent.core.agent import FCHTAgent
11
+ from fcht_agent.config.schema import AgentConfig
12
+
13
+ __all__ = ["FCHTAgent", "AgentConfig", "__version__"]
File without changes
fcht_agent/cli/main.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ FCHT Agent CLI - Command line interface.
3
+ """
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ from dataclasses import asdict
13
+
14
+ from fcht_agent.config.schema import AgentConfig
15
+ from fcht_agent.core.agent import create_agent
16
+ from fcht_agent.skills.registry import create_skill_registry, SkillMetadata
17
+
18
+
19
+ def setup_logging(level: str, json_format: bool = False) -> None:
20
+ """Configure logging."""
21
+ fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
22
+ if json_format:
23
+ try:
24
+ import pythonjsonlogger.jsonlogger
25
+ fmt = "%(asctime)s %(levelname)s %(name)s %(message)s"
26
+ handler = logging.StreamHandler()
27
+ handler.setFormatter(pythonjsonlogger.jsonlogger.JsonFormatter(fmt))
28
+ logging.getLogger().handlers = [handler]
29
+ except ImportError:
30
+ pass
31
+ else:
32
+ logging.basicConfig(level=level, format=fmt, datefmt="%H:%M:%S")
33
+
34
+
35
+ def _normalize_path(path_str: str) -> Path:
36
+ """Normalize a path string to absolute Path, handling Windows quirks and duplicated components."""
37
+ expanded = os.path.expanduser(os.path.expandvars(path_str))
38
+ normalized = os.path.normpath(expanded)
39
+ absolute = os.path.abspath(normalized)
40
+ path = Path(absolute)
41
+
42
+ parts = path.parts
43
+ if len(parts) >= 2 and parts[-1] == parts[-2]:
44
+ path = Path(*parts[:-1])
45
+
46
+ return path
47
+
48
+
49
+ def load_config(config_path: Optional[Path], working_dir: Optional[str] = None) -> AgentConfig:
50
+ """Load configuration from file or defaults. Does NOT resolve paths yet."""
51
+ if config_path and config_path.exists():
52
+ return AgentConfig.from_yaml(config_path)
53
+ return AgentConfig()
54
+
55
+
56
+ def _normalize_path(path_str: str) -> Path:
57
+ """Normalize a path string to absolute Path, handling Windows quirks and duplicated components."""
58
+ expanded = os.path.expanduser(os.path.expandvars(path_str))
59
+ normalized = os.path.normpath(expanded)
60
+ absolute = os.path.abspath(normalized)
61
+ path = Path(absolute)
62
+
63
+ parts = path.parts
64
+ if len(parts) >= 2 and parts[-1] == parts[-2]:
65
+ path = Path(*parts[:-1])
66
+
67
+ return path
68
+
69
+
70
+ def load_config(config_path: Optional[Path], working_dir: Optional[str] = None) -> AgentConfig:
71
+ """Load configuration from file or defaults. Does NOT resolve paths yet."""
72
+ if config_path and config_path.exists():
73
+ return AgentConfig.from_yaml(config_path)
74
+ return AgentConfig()
75
+
76
+
77
+ def _prepare_config(config: AgentConfig, working_dir: Optional[str] = None) -> AgentConfig:
78
+ """Apply working directory and resolve all paths."""
79
+ if working_dir:
80
+ config.working_dir = _normalize_path(working_dir)
81
+ else:
82
+ config.working_dir = _normalize_path(str(Path.cwd()))
83
+ config.resolve_paths()
84
+ return config
85
+
86
+
87
+ def _prepare_config_with_skills(config: AgentConfig, working_dir: Optional[str] = None) -> AgentConfig:
88
+ """Prepare config and ensure skills directory exists."""
89
+ config = _prepare_config(config, working_dir)
90
+ skills_dir = config.working_dir / "memory" / "skills"
91
+ skills_dir.mkdir(parents=True, exist_ok=True)
92
+ return config
93
+
94
+
95
+ def _safe_print(text: str) -> None:
96
+ """Print with ASCII fallback for Windows console."""
97
+ try:
98
+ print(text)
99
+ except UnicodeEncodeError:
100
+ print(text.replace("\u2713", "[OK]").replace("\u2717", "[ERR]"))
101
+
102
+
103
+ def cmd_run(args: argparse.Namespace) -> int:
104
+ """Run a single task."""
105
+ config = load_config(Path(args.config) if args.config else None)
106
+ config = _prepare_config_with_skills(config, args.working_dir)
107
+
108
+ setup_logging(args.log_level, args.json_logs)
109
+
110
+ if args.quiet:
111
+ logging.getLogger().setLevel(logging.WARNING)
112
+
113
+ agent = create_agent(config)
114
+ result = agent.run(args.task)
115
+
116
+ if args.json:
117
+ print(json.dumps({
118
+ "result": result.result,
119
+ "episodes_added": result.episodes_added,
120
+ "steps_taken": result.steps_taken,
121
+ "success": result.success
122
+ }))
123
+ else:
124
+ _safe_print(result.result)
125
+
126
+ return 0 if result.success else 1
127
+
128
+
129
+ def cmd_config(args: argparse.Namespace) -> int:
130
+ """Config management."""
131
+ config = load_config(Path(args.config) if args.config else None)
132
+ config = _prepare_config(config, args.working_dir)
133
+
134
+ if args.show:
135
+ _safe_print(config.model_dump_json(indent=2))
136
+ elif args.init:
137
+ out = Path(args.init)
138
+ config.to_yaml(out)
139
+ _safe_print(f"Config written to {out}")
140
+ elif args.validate:
141
+ _safe_print("Config valid [OK]")
142
+ return 0
143
+
144
+
145
+ def cmd_doctor(args: argparse.Namespace) -> int:
146
+ """Health check."""
147
+ config = load_config(Path(args.config) if args.config else None)
148
+ config = _prepare_config(config, args.working_dir)
149
+
150
+ _safe_print("FCHT Agent Health Check")
151
+ _safe_print("=" * 40)
152
+
153
+ import requests
154
+ try:
155
+ resp = requests.get(f"{config.ollama.host}/api/tags", timeout=5)
156
+ if resp.ok:
157
+ models = resp.json().get("models", [])
158
+ _safe_print(f"[OK] Ollama: {config.ollama.host}")
159
+ _safe_print(f" Models: {[m['name'] for m in models]}")
160
+ if config.ollama.model in [m['name'] for m in models]:
161
+ _safe_print(f" [OK] Target model '{config.ollama.model}' available")
162
+ else:
163
+ _safe_print(f" [ERR] Target model '{config.ollama.model}' NOT found")
164
+ else:
165
+ _safe_print(f"[ERR] Ollama: HTTP {resp.status_code}")
166
+ return 1
167
+ except Exception as e:
168
+ _safe_print(f"[ERR] Ollama: {e}")
169
+ return 1
170
+
171
+ for name, path in [("Episodic", config.memory.episodes_path), ("Semantic", config.memory.chroma_path)]:
172
+ if path.parent.exists():
173
+ _safe_print(f"[OK] {name} memory dir: {path.parent}")
174
+ else:
175
+ _safe_print(f"[ERR] {name} memory dir missing: {path.parent}")
176
+
177
+ if config.working_dir.exists():
178
+ _safe_print(f"[OK] Working dir: {config.working_dir}")
179
+ else:
180
+ _safe_print(f"[ERR] Working dir missing: {config.working_dir}")
181
+ return 1
182
+
183
+ _safe_print("\nAll checks passed [OK]")
184
+ return 0
185
+
186
+
187
+ def cmd_version(args: argparse.Namespace) -> int:
188
+ """Show version."""
189
+ from fcht_agent import __version__
190
+ _safe_print(f"fcht-agent {__version__}")
191
+ return 0
192
+
193
+
194
+ def cmd_skill(args: argparse.Namespace) -> int:
195
+ """Skill management."""
196
+ config = load_config(Path(args.config) if args.config else None)
197
+ config = _prepare_config_with_skills(config, args.working_dir)
198
+
199
+ registry = create_skill_registry(config.working_dir / "memory" / "skills")
200
+
201
+ if args.skill_list:
202
+ skills = registry.list_skills()
203
+ if args.json:
204
+ print(json.dumps([asdict(s) for s in skills]))
205
+ else:
206
+ if not skills:
207
+ _safe_print("No skills installed")
208
+ else:
209
+ _safe_print("Installed skills:")
210
+ for s in skills:
211
+ _safe_print(f" {s.name} v{s.version} - {s.description}")
212
+ elif args.create:
213
+ meta = registry.create_skill(args.create, args.description or "", args.entry_point or "")
214
+ _safe_print(f"Created skill: {meta.name} v{meta.version}")
215
+ elif args.info:
216
+ if not registry.is_installed(args.info):
217
+ _safe_print(f"[ERR] Skill not found: {args.info}")
218
+ return 1
219
+ meta = registry.get_metadata(args.info)
220
+ if args.json:
221
+ print(json.dumps(asdict(meta)))
222
+ else:
223
+ _safe_print(f"Name: {meta.name}")
224
+ _safe_print(f"Version: {meta.version}")
225
+ _safe_print(f"Description: {meta.description}")
226
+ _safe_print(f"Author: {meta.author}")
227
+ _safe_print(f"Created: {meta.created}")
228
+ _safe_print(f"Updated: {meta.updated}")
229
+ _safe_print(f"Dependencies: {', '.join(meta.dependencies) or 'none'}")
230
+ _safe_print(f"Tags: {', '.join(meta.tags) or 'none'}")
231
+ _safe_print(f"Entry point: {meta.entry_point or 'none'}")
232
+ elif args.uninstall:
233
+ if registry.uninstall_skill(args.uninstall):
234
+ _safe_print(f"Uninstalled skill: {args.uninstall}")
235
+ else:
236
+ _safe_print(f"[ERR] Skill not found: {args.uninstall}")
237
+ return 1
238
+ elif args.search:
239
+ results = registry.search_skills(args.search)
240
+ if args.json:
241
+ print(json.dumps([asdict(s) for s in results]))
242
+ else:
243
+ if not results:
244
+ _safe_print("No skills found")
245
+ else:
246
+ _safe_print(f"Found {len(results)} skill(s):")
247
+ for s in results:
248
+ _safe_print(f" {s.name} v{s.version} - {s.description}")
249
+ elif args.install:
250
+ meta = registry.install_skill(Path(args.install), overwrite=args.force)
251
+ _safe_print(f"Installed skill: {meta.name} v{meta.version}")
252
+ else:
253
+ _safe_print("Use --help for skill commands")
254
+ return 0
255
+
256
+
257
+ def _safe_print(text: str) -> None:
258
+ try:
259
+ print(text)
260
+ except UnicodeEncodeError:
261
+ print(text.replace("\u2713", "[OK]").replace("\u2717", "[ERR]"))
262
+
263
+
264
+ def main() -> int:
265
+ parser = argparse.ArgumentParser(
266
+ prog="fcht-agent",
267
+ description="First-Class Hermes Tool Agent - Offline ReAct agent with persistent memory"
268
+ )
269
+ parser.add_argument("--config", "-c", help="Path to config YAML")
270
+ parser.add_argument("--working-dir", "-w", help="Working directory")
271
+ parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
272
+ parser.add_argument("--json-logs", action="store_true", help="JSON structured logs")
273
+ parser.add_argument("--quiet", "-q", action="store_true", help="Suppress non-error output")
274
+ parser.add_argument("--json", "-j", action="store_true", help="JSON output")
275
+
276
+ subparsers = parser.add_subparsers(dest="command", required=True)
277
+
278
+ # run
279
+ run_p = subparsers.add_parser("run", help="Run a task")
280
+ run_p.add_argument("task", help="Task description")
281
+ run_p.set_defaults(func=cmd_run)
282
+
283
+ # config
284
+ config_p = subparsers.add_parser("config", help="Config management")
285
+ config_p.add_argument("--show", action="store_true", help="Show current config")
286
+ config_p.add_argument("--init", metavar="PATH", help="Write default config to file")
287
+ config_p.add_argument("--validate", action="store_true", help="Validate config")
288
+ config_p.set_defaults(func=cmd_config)
289
+
290
+ # doctor
291
+ doctor_p = subparsers.add_parser("doctor", help="Health check")
292
+ doctor_p.set_defaults(func=cmd_doctor)
293
+
294
+ # skill
295
+ skill_p = subparsers.add_parser("skill", help="Skill management")
296
+ skill_p.add_argument("--list", dest="skill_list", action="store_true", help="List installed skills")
297
+ skill_p.add_argument("--create", metavar="NAME", help="Create new skill scaffold")
298
+ skill_p.add_argument("--info", metavar="NAME", help="Show skill metadata")
299
+ skill_p.add_argument("--install", metavar="PATH", help="Install skill from directory/zip")
300
+ skill_p.add_argument("--uninstall", metavar="NAME", help="Uninstall skill")
301
+ skill_p.add_argument("--search", metavar="QUERY", help="Search skills")
302
+ skill_p.add_argument("--description", metavar="TEXT", help="Skill description (for create)")
303
+ skill_p.add_argument("--entry-point", metavar="PATH", help="Entry point (for create)")
304
+ skill_p.add_argument("--force", action="store_true", help="Overwrite existing")
305
+ skill_p.add_argument("--json", action="store_true", help="JSON output")
306
+ skill_p.set_defaults(func=cmd_skill)
307
+
308
+ # version
309
+ version_p = subparsers.add_parser("version", help="Show version")
310
+ version_p.set_defaults(func=cmd_version)
311
+
312
+ args = parser.parse_args()
313
+ return args.func(args)
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ from fcht_agent.config.schema import AgentConfig, DEFAULT_CONFIG
2
+
3
+ __all__ = ["AgentConfig", "DEFAULT_CONFIG"]
@@ -0,0 +1,102 @@
1
+ """
2
+ Configuration schema for FCHT Agent using Pydantic.
3
+ Supports YAML files, environment variables, and validation.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional, Literal
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class OllamaConfig(BaseModel):
13
+ host: str = Field(default="http://localhost:11434", description="Ollama API endpoint")
14
+ model: str = Field(default="qwen2.5:7b", description="Model name to use")
15
+ temperature: float = Field(default=0.1, ge=0.0, le=2.0, description="Sampling temperature")
16
+ timeout: int = Field(default=120, description="Request timeout in seconds")
17
+ num_gpu: int = Field(default=-1, description="GPU layers (-1=auto, 0=CPU only)")
18
+
19
+
20
+ class MemoryConfig(BaseModel):
21
+ episodes_path: Path = Field(default=Path("memory/episodes.jsonl"), description="Episodic memory file")
22
+ max_few_shot: int = Field(default=3, ge=0, le=20, description="Max episodes for few-shot")
23
+ chroma_path: Path = Field(default=Path("memory/chroma"), description="ChromaDB directory")
24
+ embed_model: str = Field(default="all-MiniLM-L6-v2", description="Sentence transformer model")
25
+ embed_cache_dir: Optional[Path] = Field(default=None, description="Embedding model cache directory")
26
+
27
+
28
+ class AgentSettings(BaseModel):
29
+ max_steps: int = Field(default=10, ge=1, le=50, description="Max ReAct steps")
30
+ system_prompt: str = Field(
31
+ default="You are a precise, helpful assistant. Use tools to accomplish tasks. "
32
+ "Output ONLY JSON tool calls. When done, respond with 'DONE: <result>'.",
33
+ description="System prompt for the agent"
34
+ )
35
+
36
+
37
+ class DaemonConfig(BaseModel):
38
+ enabled: bool = Field(default=True, description="Run daemon mode")
39
+ host: str = Field(default="127.0.0.1", description="Daemon bind address")
40
+ port: int = Field(default=8765, description="Daemon port")
41
+ max_workers: int = Field(default=4, description="Concurrent workers")
42
+
43
+
44
+ class LoggingConfig(BaseModel):
45
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO")
46
+ json_format: bool = Field(default=False, description="JSON structured logs")
47
+ file: Optional[Path] = Field(default=None, description="Log file path")
48
+
49
+
50
+ class AgentConfig(BaseSettings):
51
+ """Main configuration - loads from YAML, env vars, and defaults."""
52
+
53
+ model_config = SettingsConfigDict(
54
+ env_prefix="FCHT_",
55
+ env_nested_delimiter="__",
56
+ env_file=".env",
57
+ env_file_encoding="utf-8",
58
+ extra="ignore",
59
+ )
60
+
61
+ ollama: OllamaConfig = Field(default_factory=OllamaConfig)
62
+ memory: MemoryConfig = Field(default_factory=MemoryConfig)
63
+ agent: AgentSettings = Field(default_factory=AgentSettings)
64
+ daemon: DaemonConfig = Field(default_factory=DaemonConfig)
65
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
66
+
67
+ working_dir: Path = Field(default=Path.cwd(), description="Working directory")
68
+
69
+ @field_validator("working_dir", mode="before")
70
+ @classmethod
71
+ def resolve_working_dir(cls, v):
72
+ return Path(v).expanduser().resolve()
73
+
74
+ def resolve_paths(self) -> "AgentConfig":
75
+ """Resolve all relative paths against working_dir."""
76
+ # Only resolve if path is relative
77
+ if not self.memory.episodes_path.is_absolute():
78
+ self.memory.episodes_path = (self.working_dir / self.memory.episodes_path).resolve()
79
+ if not self.memory.chroma_path.is_absolute():
80
+ self.memory.chroma_path = (self.working_dir / self.memory.chroma_path).resolve()
81
+ if self.memory.embed_cache_dir and not self.memory.embed_cache_dir.is_absolute():
82
+ self.memory.embed_cache_dir = (self.working_dir / self.memory.embed_cache_dir).resolve()
83
+ return self
84
+
85
+ @classmethod
86
+ def from_yaml(cls, path: Path) -> "AgentConfig":
87
+ """Load from YAML file."""
88
+ import yaml
89
+ with open(path) as f:
90
+ data = yaml.safe_load(f) or {}
91
+ return cls(**data).resolve_paths()
92
+
93
+ def to_yaml(self, path: Path) -> None:
94
+ """Save to YAML file."""
95
+ import yaml
96
+ data = self.model_dump(mode="json")
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ with open(path, "w") as f:
99
+ yaml.dump(data, f, sort_keys=False)
100
+
101
+
102
+ DEFAULT_CONFIG = AgentConfig()
@@ -0,0 +1,3 @@
1
+ from fcht_agent.core.agent import FCHTAgent, create_agent, AgentResult
2
+
3
+ __all__ = ["FCHTAgent", "create_agent", "AgentResult"]
@@ -0,0 +1,214 @@
1
+ """
2
+ FCHT Agent - Core ReAct loop with memory and tools.
3
+ """
4
+
5
+ import json
6
+ import re
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional, List
10
+ from dataclasses import dataclass
11
+
12
+ import requests
13
+
14
+ from fcht_agent.config.schema import AgentConfig
15
+ from fcht_agent.memory.episodic import EpisodicMemory
16
+ from fcht_agent.memory.semantic import SemanticMemory
17
+ from fcht_agent.tools.registry import ToolRegistry
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class AgentResult:
24
+ result: str
25
+ episodes_added: int
26
+ steps_taken: int
27
+ success: bool
28
+
29
+
30
+ class FCHTAgent:
31
+ """Main agent class."""
32
+
33
+ def __init__(self, config: AgentConfig):
34
+ self.config = config
35
+ self.episodic = EpisodicMemory(config.memory.episodes_path, config.memory.max_few_shot)
36
+ self.semantic = SemanticMemory(config.memory.chroma_path, config.memory.embed_model, config.memory.embed_cache_dir)
37
+ self.tools = ToolRegistry(config.working_dir)
38
+
39
+ # Register memory tools
40
+ self._register_memory_tools()
41
+
42
+ # Ollama client
43
+ self.ollama_url = f"{config.ollama.host}/api/generate"
44
+ self.model = config.ollama.model
45
+ self.temperature = config.ollama.temperature
46
+ self.timeout = config.ollama.timeout
47
+
48
+ # System prompt
49
+ self.system_prompt = config.agent.system_prompt
50
+
51
+ def _register_memory_tools(self) -> None:
52
+ """Register memory tools with the registry."""
53
+ from fcht_agent.tools.registry import Tool
54
+
55
+ def remember(text: str, metadata: Dict = None) -> Dict[str, Any]:
56
+ return self.semantic.remember(text, metadata)
57
+
58
+ def retrieve(query: str, n_results: int = 5) -> Dict[str, Any]:
59
+ return self.semantic.retrieve(query, n_results)
60
+
61
+ self.tools.register(Tool(
62
+ name="remember",
63
+ func=remember,
64
+ description="Store a fact or document in semantic memory",
65
+ schema={
66
+ "type": "object",
67
+ "properties": {
68
+ "text": {"type": "string", "description": "Text to remember"},
69
+ "metadata": {"type": "object", "description": "Optional metadata"}
70
+ },
71
+ "required": ["text"]
72
+ }
73
+ ))
74
+
75
+ self.tools.register(Tool(
76
+ name="retrieve",
77
+ func=retrieve,
78
+ description="Search semantic memory for relevant information",
79
+ schema={
80
+ "type": "object",
81
+ "properties": {
82
+ "query": {"type": "string", "description": "Search query"},
83
+ "n_results": {"type": "integer", "default": 5}
84
+ },
85
+ "required": ["query"]
86
+ }
87
+ ))
88
+
89
+ def _call_ollama(self, prompt: str) -> str:
90
+ """Call Ollama API."""
91
+ payload = {
92
+ "model": self.model,
93
+ "prompt": prompt,
94
+ "stream": False,
95
+ "options": {"temperature": self.temperature}
96
+ }
97
+ try:
98
+ resp = requests.post(self.ollama_url, json=payload, timeout=self.timeout)
99
+ resp.raise_for_status()
100
+ return resp.json().get("response", "").strip()
101
+ except requests.RequestException as e:
102
+ logger.error(f"Ollama error: {e}")
103
+ raise
104
+
105
+ def _build_prompt(self, history: str, user_task: str) -> str:
106
+ """Build the full prompt for the model."""
107
+ few_shot = self.episodic.build_few_shot(self.config.memory.max_few_shot)
108
+ tool_docs = self.tools.format_for_prompt()
109
+
110
+ return f"""{self.system_prompt}
111
+
112
+ {tool_docs}
113
+
114
+ {few_shot}
115
+ ### Current Task
116
+ User: {user_task}
117
+
118
+ {history}
119
+ Assistant:"""
120
+
121
+ def _parse_action(self, response: str) -> Optional[Dict]:
122
+ """Parse tool call from model response."""
123
+ match = re.search(r"```json\s*(\{.*?\})\s*```", response, re.DOTALL)
124
+ if not match:
125
+ match = re.search(r'(\{[\s\S]*"tool"[\s\S]*\})', response)
126
+ if match:
127
+ try:
128
+ return json.loads(match.group(1))
129
+ except json.JSONDecodeError:
130
+ pass
131
+ return None
132
+
133
+ def _format_observation(self, result: Dict) -> str:
134
+ """Format tool result for next prompt."""
135
+ if "error" in result:
136
+ return f"Tool Error: {result['error']}"
137
+
138
+ out = []
139
+ for k, v in result.items():
140
+ if isinstance(v, str) and len(v) > 500:
141
+ v = v[:500] + "... [truncated]"
142
+ elif isinstance(v, (list, dict)):
143
+ v = json.dumps(v)[:500] + "... [truncated]" if len(json.dumps(v)) > 500 else json.dumps(v)
144
+ out.append(f"{k}: {v}")
145
+ return "Tool Result:\n" + "\n".join(out)
146
+
147
+ def run(self, user_task: str) -> AgentResult:
148
+ """Run the agent on a task."""
149
+ history = ""
150
+ episodes_before = self.episodic.count()
151
+ steps_taken = 0
152
+
153
+ for step in range(self.config.agent.max_steps):
154
+ steps_taken = step + 1
155
+ logger.debug(f"Step {steps_taken}/{self.config.agent.max_steps}")
156
+
157
+ prompt = self._build_prompt(history, user_task)
158
+ response = self._call_ollama(prompt)
159
+
160
+ if "DONE:" in response:
161
+ final = response.split("DONE:", 1)[1].strip()
162
+ self.episodic.add(user_task, final)
163
+ return AgentResult(
164
+ result=final,
165
+ episodes_added=self.episodic.count() - episodes_before,
166
+ steps_taken=steps_taken,
167
+ success=True
168
+ )
169
+
170
+ action = self._parse_action(response)
171
+ if action:
172
+ tool_name = action.get("tool")
173
+ args = action.get("args", {})
174
+ try:
175
+ result = self.tools.execute(tool_name, args)
176
+ except Exception as e:
177
+ result = {"error": str(e)}
178
+
179
+ obs = self._format_observation(result)
180
+ history += f"\nAssistant: {response}\n{obs}\n"
181
+ else:
182
+ history += f"\nAssistant: {response}\n"
183
+
184
+ return AgentResult(
185
+ result="Max steps reached without DONE.",
186
+ episodes_added=self.episodic.count() - episodes_before,
187
+ steps_taken=steps_taken,
188
+ success=False
189
+ )
190
+
191
+ def close(self) -> None:
192
+ """Close resources (ChromaDB connections, etc.)."""
193
+ try:
194
+ self.semantic.close()
195
+ except Exception:
196
+ pass
197
+ try:
198
+ self.episodic = None
199
+ except Exception:
200
+ pass
201
+
202
+ def __enter__(self):
203
+ return self
204
+
205
+ def __exit__(self, exc_type, exc_val, exc_tb):
206
+ self.close()
207
+ return False
208
+
209
+
210
+ def create_agent(config: Optional[AgentConfig] = None) -> FCHTAgent:
211
+ """Factory function to create agent from config."""
212
+ if config is None:
213
+ config = AgentConfig()
214
+ return FCHTAgent(config)