crowe-logic 0.1.0

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.
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Crowe Logic Agent — Test Harness
4
+
5
+ Runs a suite of test prompts across all domains to verify the agent
6
+ works correctly with all tools. Tests auto-tool-selection, streaming,
7
+ and multi-step reasoning.
8
+
9
+ Usage:
10
+ python scripts/test_agent.py
11
+ python scripts/test_agent.py --quick # Just 3 core tests
12
+ python scripts/test_agent.py --verbose # Show full responses
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import json
18
+ import time
19
+ import argparse
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from azure.ai.agents import AgentsClient
24
+ from azure.ai.agents.models import (
25
+ FunctionTool, ToolSet, CodeInterpreterTool,
26
+ MessageDeltaChunk, ThreadMessage, ThreadRun, AgentStreamEvent,
27
+ ListSortOrder, MessageRole,
28
+ )
29
+ from azure.identity import DefaultAzureCredential
30
+
31
+ from config.agent_config import PROJECT_ENDPOINT
32
+ from tools import user_functions
33
+
34
+ # ──────────────────────────────────────────────
35
+ # Test prompts — each exercises a different tool
36
+ # ──────────────────────────────────────────────
37
+
38
+ TESTS = [
39
+ {
40
+ "name": "filesystem_read",
41
+ "prompt": "Read the file /Users/crowelogic/Projects/crowe-logic-foundry/requirements.txt and list the packages.",
42
+ "expect_tool": "read_file",
43
+ "domain": "filesystem",
44
+ },
45
+ {
46
+ "name": "directory_list",
47
+ "prompt": "List all Python files in /Users/crowelogic/Projects/crowe-logic-foundry/tools/",
48
+ "expect_tool": "list_directory",
49
+ "domain": "filesystem",
50
+ },
51
+ {
52
+ "name": "shell_command",
53
+ "prompt": "What version of Python is installed? Run python3 --version",
54
+ "expect_tool": "execute_shell",
55
+ "domain": "shell",
56
+ },
57
+ {
58
+ "name": "grep_search",
59
+ "prompt": "Search for all occurrences of 'def ' in /Users/crowelogic/Projects/crowe-logic-foundry/tools/filesystem.py",
60
+ "expect_tool": "grep_search",
61
+ "domain": "search",
62
+ },
63
+ {
64
+ "name": "web_search",
65
+ "prompt": "Search the web for 'gpt-oss-120b benchmark results'",
66
+ "expect_tool": "web_search",
67
+ "domain": "web",
68
+ },
69
+ {
70
+ "name": "browse_url",
71
+ "prompt": "Fetch the content of https://httpbin.org/json and tell me what it contains",
72
+ "expect_tool": "browse_url",
73
+ "domain": "web",
74
+ },
75
+ {
76
+ "name": "code_interpreter",
77
+ "prompt": "Use code interpreter to calculate the first 20 Fibonacci numbers and return them as a list.",
78
+ "expect_tool": "code_interpreter",
79
+ "domain": "code",
80
+ },
81
+ {
82
+ "name": "multi_step",
83
+ "prompt": "List the Python files in /Users/crowelogic/Projects/crowe-logic-foundry/tools/, then read each one and count the total number of functions defined across all files.",
84
+ "expect_tool": "multi_step",
85
+ "domain": "reasoning",
86
+ },
87
+ ]
88
+
89
+ QUICK_TESTS = ["filesystem_read", "shell_command", "web_search"]
90
+
91
+
92
+ def run_test(client, agent_id: str, test: dict, verbose: bool = False) -> dict:
93
+ """Run a single test and return results."""
94
+ thread = client.threads.create()
95
+ client.messages.create(thread_id=thread.id, role="user", content=test["prompt"])
96
+
97
+ start = time.time()
98
+ full_response = ""
99
+
100
+ # Use create_and_process for simpler test execution
101
+ run = client.runs.create_and_process(thread_id=thread.id, agent_id=agent_id)
102
+ elapsed = time.time() - start
103
+
104
+ # Get the response
105
+ messages = client.messages.list(thread_id=thread.id, order=ListSortOrder.ASCENDING)
106
+ for msg in messages:
107
+ if msg.role == MessageRole.AGENT and msg.text_messages:
108
+ full_response = msg.text_messages[-1].text.value
109
+
110
+ passed = len(full_response) > 10 and "error" not in full_response.lower()[:100]
111
+
112
+ result = {
113
+ "name": test["name"],
114
+ "domain": test["domain"],
115
+ "passed": passed,
116
+ "elapsed": round(elapsed, 2),
117
+ "response_length": len(full_response),
118
+ "run_status": run.status,
119
+ }
120
+
121
+ if verbose:
122
+ result["response_preview"] = full_response[:500]
123
+
124
+ return result
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(description="Test the Crowe Logic agent")
129
+ parser.add_argument("--quick", action="store_true", help="Run only 3 core tests")
130
+ parser.add_argument("--verbose", "-v", action="store_true", help="Show response previews")
131
+ args = parser.parse_args()
132
+
133
+ # Load agent ID
134
+ agent_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".agent_id")
135
+ if not os.path.exists(agent_file):
136
+ print("ERROR: No agent deployed. Run: python scripts/create_agent.py")
137
+ sys.exit(1)
138
+
139
+ with open(agent_file) as f:
140
+ agent_id = json.load(f)["agent_id"]
141
+
142
+ # Connect
143
+ print(f"\n{'='*60}")
144
+ print(f" CROWE LOGIC AGENT — TEST SUITE")
145
+ print(f"{'='*60}\n")
146
+
147
+ client = AgentsClient(
148
+ endpoint=PROJECT_ENDPOINT,
149
+ credential=DefaultAzureCredential(),
150
+ )
151
+
152
+ # Setup toolset
153
+ toolset = ToolSet()
154
+ toolset.add(FunctionTool(user_functions))
155
+ toolset.add(CodeInterpreterTool())
156
+ client.enable_auto_function_calls(toolset)
157
+
158
+ # Select tests
159
+ tests = TESTS
160
+ if args.quick:
161
+ tests = [t for t in TESTS if t["name"] in QUICK_TESTS]
162
+
163
+ print(f" Running {len(tests)} tests against agent {agent_id[:20]}...\n")
164
+
165
+ # Run tests
166
+ results = []
167
+ for i, test in enumerate(tests, 1):
168
+ print(f" [{i}/{len(tests)}] {test['name']:25s} ({test['domain']}) ... ", end="", flush=True)
169
+ try:
170
+ result = run_test(client, agent_id, test, verbose=args.verbose)
171
+ status = "PASS" if result["passed"] else "FAIL"
172
+ color = "" # no ANSI in basic print
173
+ print(f"{status} ({result['elapsed']}s, {result['response_length']} chars)")
174
+ if args.verbose and "response_preview" in result:
175
+ print(f" Response: {result['response_preview'][:200]}...")
176
+ print()
177
+ except Exception as e:
178
+ result = {"name": test["name"], "domain": test["domain"], "passed": False, "error": str(e)}
179
+ print(f"ERROR ({str(e)[:60]})")
180
+ results.append(result)
181
+
182
+ # Summary
183
+ passed = sum(1 for r in results if r.get("passed"))
184
+ failed = len(results) - passed
185
+ print(f"\n{'='*60}")
186
+ print(f" Results: {passed} passed, {failed} failed, {len(results)} total")
187
+ print(f"{'='*60}\n")
188
+
189
+ if failed > 0:
190
+ print(" Failed tests:")
191
+ for r in results:
192
+ if not r.get("passed"):
193
+ print(f" - {r['name']}: {r.get('error', r.get('run_status', 'unknown'))}")
194
+ print()
195
+
196
+ sys.exit(0 if failed == 0 else 1)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
package/setup.py ADDED
@@ -0,0 +1,24 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="crowe-logic",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ install_requires=[
8
+ "azure-ai-agents>=1.1.0",
9
+ "azure-identity>=1.17.0",
10
+ "click>=8.1.0",
11
+ "rich>=13.0.0",
12
+ "prompt-toolkit>=3.0.0",
13
+ "python-dotenv>=1.0.0",
14
+ "httpx>=0.27.0",
15
+ "beautifulsoup4>=4.12.0",
16
+ ],
17
+ entry_points={
18
+ "console_scripts": [
19
+ "crowe-logic=cli.crowe_logic:main",
20
+ ],
21
+ },
22
+ author="Michael Crowe",
23
+ description="Crowe Logic — Universal AI Agent powered by gpt-oss-120b on Azure AI Foundry",
24
+ )
@@ -0,0 +1,57 @@
1
+ """
2
+ Crowe Logic Agent — Complete Tool Suite
3
+
4
+ All functions are registered as FunctionTool with Azure AI Agent Service.
5
+ The agent auto-selects and executes them based on the conversation.
6
+ """
7
+
8
+ # Core tools (filesystem, shell, web)
9
+ from tools.filesystem import read_file, write_file, edit_file, list_directory
10
+ from tools.shell import execute_shell
11
+ from tools.search import web_search, grep_search
12
+ from tools.browser import browse_url
13
+
14
+ # macOS automation
15
+ from tools.applescript import run_applescript, open_application, send_notification
16
+
17
+ # Git operations
18
+ from tools.git_ops import git_status, git_diff, git_log, git_commit, git_clone
19
+
20
+ # Browser automation (Playwright)
21
+ from tools.playwright_browser import (
22
+ browser_navigate, browser_click, browser_type_text,
23
+ browser_snapshot, browser_screenshot,
24
+ )
25
+
26
+ # Talon Music Engine
27
+ from tools.talon_music import (
28
+ talon_compose, talon_import_midi, talon_analyze,
29
+ talon_transform, talon_export,
30
+ )
31
+
32
+ # Quantum computing
33
+ from tools.quantum import run_quantum_circuit, synapse_evaluate, qubit_flow_execute
34
+
35
+ # All user-facing functions the agent can call
36
+ user_functions = {
37
+ # Filesystem
38
+ read_file, write_file, edit_file, list_directory,
39
+ # Shell
40
+ execute_shell,
41
+ # Search
42
+ web_search, grep_search,
43
+ # Web browsing
44
+ browse_url,
45
+ # macOS
46
+ run_applescript, open_application, send_notification,
47
+ # Git
48
+ git_status, git_diff, git_log, git_commit, git_clone,
49
+ # Playwright browser
50
+ browser_navigate, browser_click, browser_type_text,
51
+ browser_snapshot, browser_screenshot,
52
+ # Talon Music
53
+ talon_compose, talon_import_midi, talon_analyze,
54
+ talon_transform, talon_export,
55
+ # Quantum
56
+ run_quantum_circuit, synapse_evaluate, qubit_flow_execute,
57
+ }
@@ -0,0 +1,58 @@
1
+ """
2
+ AppleScript tool — macOS automation via osascript.
3
+ """
4
+
5
+ import json
6
+ import subprocess
7
+
8
+
9
+ def run_applescript(script: str) -> str:
10
+ """
11
+ Execute an AppleScript and return the result. Can automate any macOS
12
+ application — Finder, Safari, Terminal, System Events, etc.
13
+
14
+ :param script: The AppleScript code to execute.
15
+ :return: JSON with the script output or error.
16
+ :rtype: str
17
+ """
18
+ try:
19
+ result = subprocess.run(
20
+ ["osascript", "-e", script],
21
+ capture_output=True,
22
+ text=True,
23
+ timeout=30,
24
+ )
25
+ return json.dumps({
26
+ "stdout": result.stdout.strip(),
27
+ "stderr": result.stderr.strip() if result.stderr else "",
28
+ "return_code": result.returncode,
29
+ })
30
+ except subprocess.TimeoutExpired:
31
+ return json.dumps({"error": "AppleScript timed out after 30s"})
32
+ except Exception as e:
33
+ return json.dumps({"error": str(e)})
34
+
35
+
36
+ def open_application(app_name: str) -> str:
37
+ """
38
+ Open a macOS application by name. Works with any installed app.
39
+
40
+ :param app_name: Name of the application (e.g. "Ableton Live", "Safari", "Terminal").
41
+ :return: JSON confirmation.
42
+ :rtype: str
43
+ """
44
+ script = f'tell application "{app_name}" to activate'
45
+ return run_applescript(script)
46
+
47
+
48
+ def send_notification(title: str, message: str) -> str:
49
+ """
50
+ Send a macOS notification with a title and message.
51
+
52
+ :param title: The notification title.
53
+ :param message: The notification body text.
54
+ :return: JSON confirmation.
55
+ :rtype: str
56
+ """
57
+ script = f'display notification "{message}" with title "{title}"'
58
+ return run_applescript(script)
@@ -0,0 +1,79 @@
1
+ """
2
+ Browser tool — fetch and extract content from web pages.
3
+ """
4
+
5
+ import json
6
+
7
+
8
+ def browse_url(url: str, extract_mode: str = "text") -> str:
9
+ """
10
+ Fetch a web page and extract its content. Supports text extraction
11
+ and raw HTML modes.
12
+
13
+ :param url: The URL to fetch (must start with http:// or https://).
14
+ :param extract_mode: "text" for readable text, "html" for raw HTML, "links" for all links.
15
+ :return: JSON with the extracted page content.
16
+ :rtype: str
17
+ """
18
+ import httpx
19
+ from bs4 import BeautifulSoup
20
+
21
+ if not url.startswith(("http://", "https://")):
22
+ url = "https://" + url
23
+
24
+ try:
25
+ response = httpx.get(
26
+ url,
27
+ headers={
28
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) CroweLogic/0.1",
29
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
30
+ },
31
+ timeout=20,
32
+ follow_redirects=True,
33
+ )
34
+ response.raise_for_status()
35
+
36
+ content_type = response.headers.get("content-type", "")
37
+ if "json" in content_type:
38
+ return json.dumps({
39
+ "url": str(response.url),
40
+ "status": response.status_code,
41
+ "content_type": "json",
42
+ "body": response.json(),
43
+ })
44
+
45
+ soup = BeautifulSoup(response.text, "html.parser")
46
+
47
+ # Remove script, style, nav, footer noise
48
+ for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
49
+ tag.decompose()
50
+
51
+ if extract_mode == "html":
52
+ body = str(soup)[:100000]
53
+ return json.dumps({"url": str(response.url), "status": response.status_code, "html": body})
54
+
55
+ if extract_mode == "links":
56
+ links = []
57
+ for a in soup.find_all("a", href=True):
58
+ links.append({"text": a.get_text(strip=True)[:200], "href": a["href"]})
59
+ return json.dumps({"url": str(response.url), "status": response.status_code, "links": links[:200]})
60
+
61
+ # Default: text extraction
62
+ title = soup.title.string.strip() if soup.title and soup.title.string else ""
63
+ text = soup.get_text(separator="\n", strip=True)
64
+ # Collapse multiple blank lines
65
+ lines = [line for line in text.splitlines() if line.strip()]
66
+ text = "\n".join(lines)
67
+ if len(text) > 80000:
68
+ text = text[:80000] + "\n... (content truncated)"
69
+
70
+ return json.dumps({
71
+ "url": str(response.url),
72
+ "status": response.status_code,
73
+ "title": title,
74
+ "text": text,
75
+ })
76
+ except httpx.HTTPStatusError as e:
77
+ return json.dumps({"error": f"HTTP {e.response.status_code}: {str(e)}", "url": url})
78
+ except Exception as e:
79
+ return json.dumps({"error": str(e), "url": url})
@@ -0,0 +1,115 @@
1
+ """
2
+ Filesystem tools — read, write, edit, and list files.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ from pathlib import Path
8
+
9
+
10
+ def read_file(file_path: str, offset: int = 0, limit: int = 0) -> str:
11
+ """
12
+ Read the contents of a file. Returns the file content as a string.
13
+
14
+ :param file_path: Absolute path to the file to read.
15
+ :param offset: Line number to start reading from (0-based, default 0).
16
+ :param limit: Maximum number of lines to return (0 = unlimited).
17
+ :return: File contents with line numbers prefixed.
18
+ :rtype: str
19
+ """
20
+ path = Path(file_path).expanduser().resolve()
21
+ if not path.exists():
22
+ return json.dumps({"error": f"File not found: {file_path}"})
23
+ if not path.is_file():
24
+ return json.dumps({"error": f"Not a file: {file_path}"})
25
+ try:
26
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
27
+ if offset > 0:
28
+ lines = lines[offset:]
29
+ if limit > 0:
30
+ lines = lines[:limit]
31
+ numbered = [f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(lines)]
32
+ return "\n".join(numbered)
33
+ except Exception as e:
34
+ return json.dumps({"error": str(e)})
35
+
36
+
37
+ def write_file(file_path: str, content: str) -> str:
38
+ """
39
+ Write content to a file, creating it if it doesn't exist.
40
+ Overwrites existing content.
41
+
42
+ :param file_path: Absolute path to the file to write.
43
+ :param content: The full content to write to the file.
44
+ :return: Confirmation message with bytes written.
45
+ :rtype: str
46
+ """
47
+ path = Path(file_path).expanduser().resolve()
48
+ try:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ path.write_text(content, encoding="utf-8")
51
+ return json.dumps({"success": True, "path": str(path), "bytes": len(content.encode("utf-8"))})
52
+ except Exception as e:
53
+ return json.dumps({"error": str(e)})
54
+
55
+
56
+ def edit_file(file_path: str, old_string: str, new_string: str) -> str:
57
+ """
58
+ Replace an exact string in a file. The old_string must appear exactly once
59
+ in the file for the replacement to succeed.
60
+
61
+ :param file_path: Absolute path to the file to edit.
62
+ :param old_string: The exact text to find and replace.
63
+ :param new_string: The replacement text.
64
+ :return: Confirmation or error message.
65
+ :rtype: str
66
+ """
67
+ path = Path(file_path).expanduser().resolve()
68
+ if not path.exists():
69
+ return json.dumps({"error": f"File not found: {file_path}"})
70
+ try:
71
+ text = path.read_text(encoding="utf-8")
72
+ count = text.count(old_string)
73
+ if count == 0:
74
+ return json.dumps({"error": "old_string not found in file"})
75
+ if count > 1:
76
+ return json.dumps({"error": f"old_string found {count} times — must be unique. Provide more context."})
77
+ new_text = text.replace(old_string, new_string, 1)
78
+ path.write_text(new_text, encoding="utf-8")
79
+ return json.dumps({"success": True, "path": str(path)})
80
+ except Exception as e:
81
+ return json.dumps({"error": str(e)})
82
+
83
+
84
+ def list_directory(directory_path: str, pattern: str = "*", recursive: bool = False) -> str:
85
+ """
86
+ List files and directories at the given path. Supports glob patterns.
87
+
88
+ :param directory_path: Absolute path to the directory to list.
89
+ :param pattern: Glob pattern to filter results (default "*").
90
+ :param recursive: If true, search recursively using "**/" prefix.
91
+ :return: JSON list of file/directory entries with type and size.
92
+ :rtype: str
93
+ """
94
+ path = Path(directory_path).expanduser().resolve()
95
+ if not path.exists():
96
+ return json.dumps({"error": f"Directory not found: {directory_path}"})
97
+ if not path.is_dir():
98
+ return json.dumps({"error": f"Not a directory: {directory_path}"})
99
+ try:
100
+ if recursive:
101
+ entries = sorted(path.rglob(pattern))
102
+ else:
103
+ entries = sorted(path.glob(pattern))
104
+
105
+ results = []
106
+ for entry in entries[:500]: # Cap at 500 to avoid huge outputs
107
+ results.append({
108
+ "name": entry.name,
109
+ "path": str(entry),
110
+ "type": "dir" if entry.is_dir() else "file",
111
+ "size": entry.stat().st_size if entry.is_file() else None,
112
+ })
113
+ return json.dumps({"count": len(results), "entries": results})
114
+ except Exception as e:
115
+ return json.dumps({"error": str(e)})
@@ -0,0 +1,107 @@
1
+ """
2
+ Git operations tool — repo management, commits, branches, diffs.
3
+ """
4
+
5
+ import json
6
+ import subprocess
7
+ import os
8
+
9
+
10
+ def git_status(repo_path: str) -> str:
11
+ """
12
+ Show the working tree status of a git repository.
13
+
14
+ :param repo_path: Absolute path to the git repository.
15
+ :return: JSON with status output (staged, unstaged, untracked files).
16
+ :rtype: str
17
+ """
18
+ return _git(repo_path, ["status", "--porcelain=v2", "--branch"])
19
+
20
+
21
+ def git_diff(repo_path: str, staged: bool = False) -> str:
22
+ """
23
+ Show changes in the working directory or staging area.
24
+
25
+ :param repo_path: Absolute path to the git repository.
26
+ :param staged: If true, show staged changes. Default shows unstaged.
27
+ :return: JSON with diff output.
28
+ :rtype: str
29
+ """
30
+ cmd = ["diff", "--stat"]
31
+ if staged:
32
+ cmd.append("--cached")
33
+ return _git(repo_path, cmd)
34
+
35
+
36
+ def git_log(repo_path: str, count: int = 10) -> str:
37
+ """
38
+ Show recent commit history.
39
+
40
+ :param repo_path: Absolute path to the git repository.
41
+ :param count: Number of commits to show (default 10).
42
+ :return: JSON with commit history (hash, author, date, message).
43
+ :rtype: str
44
+ """
45
+ count = min(count, 50)
46
+ return _git(repo_path, ["log", f"-{count}", "--oneline", "--decorate"])
47
+
48
+
49
+ def git_commit(repo_path: str, message: str, files: str = ".") -> str:
50
+ """
51
+ Stage files and create a commit.
52
+
53
+ :param repo_path: Absolute path to the git repository.
54
+ :param message: The commit message.
55
+ :param files: Files to stage (default "." for all changes).
56
+ :return: JSON with commit result.
57
+ :rtype: str
58
+ """
59
+ add_result = _git(repo_path, ["add", files])
60
+ if "error" in add_result:
61
+ return add_result
62
+ return _git(repo_path, ["commit", "-m", message])
63
+
64
+
65
+ def git_clone(url: str, target_path: str) -> str:
66
+ """
67
+ Clone a git repository.
68
+
69
+ :param url: The repository URL to clone.
70
+ :param target_path: Local path to clone into.
71
+ :return: JSON with clone result.
72
+ :rtype: str
73
+ """
74
+ try:
75
+ result = subprocess.run(
76
+ ["git", "clone", url, target_path],
77
+ capture_output=True, text=True, timeout=120,
78
+ )
79
+ return json.dumps({
80
+ "stdout": result.stdout.strip(),
81
+ "stderr": result.stderr.strip(),
82
+ "return_code": result.returncode,
83
+ })
84
+ except Exception as e:
85
+ return json.dumps({"error": str(e)})
86
+
87
+
88
+ def _git(repo_path: str, args: list) -> str:
89
+ """Run a git command in the given repo."""
90
+ repo = os.path.expanduser(repo_path)
91
+ if not os.path.isdir(os.path.join(repo, ".git")):
92
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
93
+ try:
94
+ result = subprocess.run(
95
+ ["git"] + args,
96
+ capture_output=True, text=True, timeout=30, cwd=repo,
97
+ )
98
+ output = result.stdout.strip()
99
+ if len(output) > 50000:
100
+ output = output[:50000] + "\n... (truncated)"
101
+ return json.dumps({
102
+ "output": output,
103
+ "stderr": result.stderr.strip() if result.stderr else "",
104
+ "return_code": result.returncode,
105
+ })
106
+ except Exception as e:
107
+ return json.dumps({"error": str(e)})