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 +13 -0
- fcht_agent/cli/__init__.py +0 -0
- fcht_agent/cli/main.py +317 -0
- fcht_agent/config/__init__.py +3 -0
- fcht_agent/config/schema.py +102 -0
- fcht_agent/core/__init__.py +3 -0
- fcht_agent/core/agent.py +214 -0
- fcht_agent/daemon/server.py +164 -0
- fcht_agent/memory/__init__.py +5 -0
- fcht_agent/memory/embeddings.py +77 -0
- fcht_agent/memory/episodic.py +71 -0
- fcht_agent/memory/semantic.py +172 -0
- fcht_agent/skills/__init__.py +3 -0
- fcht_agent/skills/model_downloader.py +49 -0
- fcht_agent/skills/registry.py +222 -0
- fcht_agent/tools/__init__.py +4 -0
- fcht_agent/tools/comfyui.py +220 -0
- fcht_agent/tools/registry.py +296 -0
- fcht_agent-0.1.0.dist-info/METADATA +255 -0
- fcht_agent-0.1.0.dist-info/RECORD +24 -0
- fcht_agent-0.1.0.dist-info/WHEEL +5 -0
- fcht_agent-0.1.0.dist-info/entry_points.txt +3 -0
- fcht_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- fcht_agent-0.1.0.dist-info/top_level.txt +1 -0
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,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()
|
fcht_agent/core/agent.py
ADDED
|
@@ -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)
|