intentos 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.
- intentos/__init__.py +3 -0
- intentos/backbone/__init__.py +0 -0
- intentos/backbone/bus.py +114 -0
- intentos/backbone/event.py +60 -0
- intentos/backbone/schema.py +68 -0
- intentos/backbone/store.py +162 -0
- intentos/capabilities/__init__.py +44 -0
- intentos/capabilities/llm.py +25 -0
- intentos/capabilities/report.py +26 -0
- intentos/capabilities/review.py +12 -0
- intentos/capabilities/search.py +111 -0
- intentos/capability_pool.py +99 -0
- intentos/cli.py +518 -0
- intentos/execution_engine.py +242 -0
- intentos/llm_executor.py +108 -0
- intentos/models.py +263 -0
- intentos/observability.py +290 -0
- intentos/planner.py +132 -0
- intentos/registry/__init__.py +164 -0
- intentos/registry/manifest.py +42 -0
- intentos/registry/workflows.py +87 -0
- intentos/reporter.py +80 -0
- intentos/reviewer.py +87 -0
- intentos/sdk/__init__.py +29 -0
- intentos/sdk/loader.py +199 -0
- intentos/sdk/scaffold.py +227 -0
- intentos/sdk/validator.py +189 -0
- intentos/task_executors/__init__.py +1 -0
- intentos/workflow_loader.py +127 -0
- intentos-0.1.0.dist-info/METADATA +234 -0
- intentos-0.1.0.dist-info/RECORD +34 -0
- intentos-0.1.0.dist-info/WHEEL +4 -0
- intentos-0.1.0.dist-info/entry_points.txt +4 -0
- intentos-0.1.0.dist-info/licenses/LICENSE +280 -0
intentos/__init__.py
ADDED
|
File without changes
|
intentos/backbone/bus.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""In-memory Event Bus — RFC-0500 §5.
|
|
2
|
+
|
|
3
|
+
At-least-once delivery, push subscriptions, dead-letter queue.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import enum
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from .event import Event
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeliveryStatus(enum.Enum):
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
DELIVERED = "delivered"
|
|
18
|
+
DEAD_LETTER = "dead_letter"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeadLetterEntry:
|
|
22
|
+
"""An event that exhausted delivery retries."""
|
|
23
|
+
def __init__(self, event: Event, subscriber_id: str, attempts: list[dict]):
|
|
24
|
+
self.event = event
|
|
25
|
+
self.subscriber_id = subscriber_id
|
|
26
|
+
self.attempts = attempts
|
|
27
|
+
self.moved_at: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_SubCallback = Callable[[Event], bool | None] # return False to nack
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EventBus:
|
|
34
|
+
"""In-memory pub/sub with at-least-once delivery."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._subscriptions: dict[str, list[_SubCallback]] = defaultdict(list)
|
|
39
|
+
self._dead_letter: list[DeadLetterEntry] = []
|
|
40
|
+
self._sequence = 0
|
|
41
|
+
|
|
42
|
+
# ── subscription ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def subscribe(self, event_type_prefix: str, callback: _SubCallback):
|
|
45
|
+
"""Register a callback for events whose type starts with *prefix*."""
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._subscriptions[event_type_prefix].append(callback)
|
|
48
|
+
|
|
49
|
+
def unsubscribe(self, event_type_prefix: str, callback: _SubCallback):
|
|
50
|
+
with self._lock:
|
|
51
|
+
try:
|
|
52
|
+
self._subscriptions[event_type_prefix].remove(callback)
|
|
53
|
+
except ValueError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# ── publish ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def publish(self, event: Event) -> list[DeadLetterEntry]:
|
|
59
|
+
"""Deliver *event* to all matching subscribers.
|
|
60
|
+
|
|
61
|
+
Returns any dead-letter entries created during delivery.
|
|
62
|
+
"""
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._sequence += 1
|
|
65
|
+
event.metadata["sequence_id"] = self._sequence
|
|
66
|
+
|
|
67
|
+
dead = []
|
|
68
|
+
for prefix, callbacks in self._matching(event.event_type):
|
|
69
|
+
for cb in callbacks:
|
|
70
|
+
attempts: list[dict] = []
|
|
71
|
+
ok = False
|
|
72
|
+
for attempt in range(1, 4): # max_retries=3
|
|
73
|
+
try:
|
|
74
|
+
result = cb(event)
|
|
75
|
+
if result is False:
|
|
76
|
+
raise ValueError("subscriber nack")
|
|
77
|
+
ok = True
|
|
78
|
+
break
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
attempts.append({
|
|
81
|
+
"attempt": attempt,
|
|
82
|
+
"error": str(exc),
|
|
83
|
+
"at": time.time(),
|
|
84
|
+
})
|
|
85
|
+
time.sleep(0.1 * attempt) # simple backoff
|
|
86
|
+
|
|
87
|
+
if not ok:
|
|
88
|
+
entry = DeadLetterEntry(event, prefix, attempts)
|
|
89
|
+
entry.moved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
90
|
+
self._dead_letter.append(entry)
|
|
91
|
+
dead.append(entry)
|
|
92
|
+
return dead
|
|
93
|
+
|
|
94
|
+
def _matching(self, event_type: str) -> list[tuple[str, list]]:
|
|
95
|
+
with self._lock:
|
|
96
|
+
return [(p, list(cbs)) for p, cbs in self._subscriptions.items()
|
|
97
|
+
if event_type.startswith(p)]
|
|
98
|
+
|
|
99
|
+
# ── dead-letter ─────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def dead_letter_queue(self) -> list[DeadLetterEntry]:
|
|
103
|
+
return list(self._dead_letter)
|
|
104
|
+
|
|
105
|
+
def replay_dead_letter(self) -> list[DeadLetterEntry]:
|
|
106
|
+
"""Re-attempt delivery of all dead-letter events."""
|
|
107
|
+
pending = list(self._dead_letter)
|
|
108
|
+
self._dead_letter.clear()
|
|
109
|
+
still_dead = []
|
|
110
|
+
for entry in pending:
|
|
111
|
+
event = entry.event
|
|
112
|
+
dead = self.publish(event)
|
|
113
|
+
still_dead.extend(dead)
|
|
114
|
+
return still_dead
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Event envelope — RFC-0500 §4."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Event:
|
|
12
|
+
"""Canonical Event envelope per SPEC-0000 §3.9 / RFC-0500 §4."""
|
|
13
|
+
event_id: str
|
|
14
|
+
event_type: str
|
|
15
|
+
version: int = 1
|
|
16
|
+
|
|
17
|
+
source: dict = field(default_factory=lambda: {"module": "unknown", "instance_id": ""})
|
|
18
|
+
payload: dict = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
metadata: dict = field(default_factory=lambda: {
|
|
21
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
22
|
+
"sequence_id": 0,
|
|
23
|
+
"content_type": "application/json",
|
|
24
|
+
"size_bytes": 0,
|
|
25
|
+
})
|
|
26
|
+
context: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def new(cls, event_type: str, payload: dict, *,
|
|
30
|
+
source: dict | None = None,
|
|
31
|
+
context: dict | None = None,
|
|
32
|
+
sequence_id: int = 0) -> "Event":
|
|
33
|
+
raw = json.dumps(payload, default=str).encode()
|
|
34
|
+
return cls(
|
|
35
|
+
event_id=f"event://ref/{uuid4()}",
|
|
36
|
+
event_type=event_type,
|
|
37
|
+
source=source or {"module": "unknown", "instance_id": ""},
|
|
38
|
+
payload=payload,
|
|
39
|
+
metadata={
|
|
40
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
41
|
+
"sequence_id": sequence_id,
|
|
42
|
+
"content_type": "application/json",
|
|
43
|
+
"size_bytes": len(raw),
|
|
44
|
+
},
|
|
45
|
+
context=context or {},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_json(self) -> str:
|
|
49
|
+
return json.dumps({
|
|
50
|
+
"event_id": self.event_id,
|
|
51
|
+
"event_type": self.event_type,
|
|
52
|
+
"version": self.version,
|
|
53
|
+
"source": self.source,
|
|
54
|
+
"payload": self.payload,
|
|
55
|
+
"metadata": self.metadata,
|
|
56
|
+
"context": self.context,
|
|
57
|
+
}, default=str, indent=2)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"Event({self.event_type}, id={self.event_id[:24]}…)"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Schema Registry — RFC-0500 §7."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaRegistry:
|
|
9
|
+
"""Event type registration and publish-time validation."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._schemas: dict[str, dict[int, dict]] = {} # event_type → {version → schema}
|
|
13
|
+
|
|
14
|
+
def register(self, event_type: str, version: int, schema: dict[str, Any],
|
|
15
|
+
*, description: str = "", producer: str = "",
|
|
16
|
+
consumers: list[str] | None = None) -> None:
|
|
17
|
+
if version in self._schemas.get(event_type, {}):
|
|
18
|
+
raise ValueError(f"Schema {event_type} v{version} already registered")
|
|
19
|
+
self._schemas.setdefault(event_type, {})[version] = {
|
|
20
|
+
"schema": schema,
|
|
21
|
+
"description": description,
|
|
22
|
+
"producer": producer,
|
|
23
|
+
"consumers": consumers or [],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def validate(self, event_type: str, version: int, payload: dict) -> None:
|
|
27
|
+
entry = self._schemas.get(event_type, {}).get(version)
|
|
28
|
+
if entry is None:
|
|
29
|
+
raise ValueError(f"Schema not registered: {event_type} v{version}")
|
|
30
|
+
|
|
31
|
+
schema = entry["schema"]
|
|
32
|
+
required = schema.get("required", [])
|
|
33
|
+
props = schema.get("properties", {})
|
|
34
|
+
|
|
35
|
+
for field in required:
|
|
36
|
+
if field not in payload:
|
|
37
|
+
raise ValueError(f"Missing required field '{field}' in {event_type} v{version}")
|
|
38
|
+
|
|
39
|
+
for field, value in payload.items():
|
|
40
|
+
if field not in props:
|
|
41
|
+
continue
|
|
42
|
+
prop_schema = props[field]
|
|
43
|
+
self._validate_type(field, value, prop_schema)
|
|
44
|
+
|
|
45
|
+
def _validate_type(self, field: str, value: Any, prop_schema: dict):
|
|
46
|
+
kind = prop_schema.get("type")
|
|
47
|
+
if kind == "string":
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
raise TypeError(f"Field '{field}' should be string, got {type(value).__name__}")
|
|
50
|
+
if "pattern" in prop_schema:
|
|
51
|
+
import re
|
|
52
|
+
if not re.match(prop_schema["pattern"], str(value)):
|
|
53
|
+
raise ValueError(f"Field '{field}' does not match pattern {prop_schema['pattern']}")
|
|
54
|
+
elif kind == "integer":
|
|
55
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
56
|
+
raise TypeError(f"Field '{field}' should be integer, got {type(value).__name__}")
|
|
57
|
+
elif kind == "number":
|
|
58
|
+
if not isinstance(value, (int, float)):
|
|
59
|
+
raise TypeError(f"Field '{field}' should be number, got {type(value).__name__}")
|
|
60
|
+
elif kind == "boolean":
|
|
61
|
+
if not isinstance(value, bool):
|
|
62
|
+
raise TypeError(f"Field '{field}' should be boolean, got {type(value).__name__}")
|
|
63
|
+
elif kind == "array":
|
|
64
|
+
if not isinstance(value, list):
|
|
65
|
+
raise TypeError(f"Field '{field}' should be array, got {type(value).__name__}")
|
|
66
|
+
elif kind == "object":
|
|
67
|
+
if not isinstance(value, dict):
|
|
68
|
+
raise TypeError(f"Field '{field}' should be object, got {type(value).__name__}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""SQLite-backed Event Store — RFC-0500 §6.
|
|
2
|
+
|
|
3
|
+
Append-only log with replay, snapshot, and compaction support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .event import Event
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventStore:
|
|
18
|
+
"""Append-only Event Store backed by SQLite."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, db_path: str | Path):
|
|
21
|
+
self._path = Path(db_path)
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
|
|
24
|
+
self._conn.row_factory = sqlite3.Row
|
|
25
|
+
self._init_schema()
|
|
26
|
+
|
|
27
|
+
def _init_schema(self):
|
|
28
|
+
self._conn.executescript(f"""
|
|
29
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
30
|
+
event_id TEXT PRIMARY KEY,
|
|
31
|
+
event_type TEXT NOT NULL,
|
|
32
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
33
|
+
source_module TEXT NOT NULL,
|
|
34
|
+
execution_id TEXT,
|
|
35
|
+
session_id TEXT,
|
|
36
|
+
payload TEXT NOT NULL,
|
|
37
|
+
metadata TEXT NOT NULL,
|
|
38
|
+
context TEXT NOT NULL DEFAULT '{{}}',
|
|
39
|
+
published_at TEXT NOT NULL,
|
|
40
|
+
ingested_at TEXT NOT NULL DEFAULT (strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', 'now'))
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
43
|
+
snapshot_id TEXT PRIMARY KEY,
|
|
44
|
+
execution_id TEXT NOT NULL,
|
|
45
|
+
at_sequence INTEGER NOT NULL,
|
|
46
|
+
state TEXT NOT NULL,
|
|
47
|
+
created_at TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_events_execution
|
|
50
|
+
ON events(execution_id, json_extract(metadata, '$.sequence_id'));
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_events_type_time
|
|
52
|
+
ON events(event_type, published_at);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_events_source
|
|
54
|
+
ON events(source_module, published_at);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_execution
|
|
56
|
+
ON snapshots(execution_id, at_sequence DESC);
|
|
57
|
+
""")
|
|
58
|
+
self._conn.commit()
|
|
59
|
+
|
|
60
|
+
# ── write ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def append(self, event: Event) -> str:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._conn.execute(
|
|
65
|
+
"""INSERT INTO events
|
|
66
|
+
(event_id, event_type, version, source_module,
|
|
67
|
+
execution_id, session_id, payload, metadata, context,
|
|
68
|
+
published_at)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
70
|
+
(
|
|
71
|
+
event.event_id,
|
|
72
|
+
event.event_type,
|
|
73
|
+
event.version,
|
|
74
|
+
event.source.get("module", "unknown"),
|
|
75
|
+
event.context.get("execution_id"),
|
|
76
|
+
event.context.get("session_id"),
|
|
77
|
+
json.dumps(event.payload, default=str),
|
|
78
|
+
json.dumps(event.metadata, default=str),
|
|
79
|
+
json.dumps(event.context, default=str),
|
|
80
|
+
event.metadata.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
self._conn.commit()
|
|
84
|
+
return event.event_id
|
|
85
|
+
|
|
86
|
+
# ── replay ──────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def replay(self, *, execution_id: str | None = None,
|
|
89
|
+
event_type: str | None = None,
|
|
90
|
+
from_sequence: int | None = None,
|
|
91
|
+
limit: int = 0) -> Generator[dict[str, Any], None, None]:
|
|
92
|
+
"""Yield stored events as dicts matching the filter criteria."""
|
|
93
|
+
clauses: list[str] = []
|
|
94
|
+
params: list[Any] = []
|
|
95
|
+
|
|
96
|
+
if execution_id:
|
|
97
|
+
clauses.append("execution_id = ?")
|
|
98
|
+
params.append(execution_id)
|
|
99
|
+
if event_type:
|
|
100
|
+
clauses.append("event_type = ?")
|
|
101
|
+
params.append(event_type)
|
|
102
|
+
if from_sequence is not None:
|
|
103
|
+
clauses.append("CAST(json_extract(metadata, '$.sequence_id') AS INTEGER) >= ?")
|
|
104
|
+
params.append(from_sequence)
|
|
105
|
+
|
|
106
|
+
where = " AND ".join(clauses) if clauses else "1"
|
|
107
|
+
sql = f"SELECT * FROM events WHERE {where} ORDER BY rowid"
|
|
108
|
+
if limit:
|
|
109
|
+
sql += f" LIMIT {limit}"
|
|
110
|
+
|
|
111
|
+
with self._lock:
|
|
112
|
+
# fmt:off
|
|
113
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
114
|
+
# fmt:on
|
|
115
|
+
|
|
116
|
+
for row in rows:
|
|
117
|
+
yield {
|
|
118
|
+
"event_id": row["event_id"],
|
|
119
|
+
"event_type": row["event_type"],
|
|
120
|
+
"version": row["version"],
|
|
121
|
+
"source": {"module": row["source_module"]},
|
|
122
|
+
"payload": json.loads(row["payload"]),
|
|
123
|
+
"metadata": json.loads(row["metadata"]),
|
|
124
|
+
"context": json.loads(row["context"]),
|
|
125
|
+
"published_at": row["published_at"],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# ── snapshot / compaction (RFC-0500 §6.3) ───────────────────────
|
|
129
|
+
|
|
130
|
+
def save_snapshot(self, execution_id: str, at_sequence: int,
|
|
131
|
+
state: dict[str, Any]) -> str:
|
|
132
|
+
snap_id = f"snapshot://ref/{execution_id}/{at_sequence}"
|
|
133
|
+
self._conn.execute(
|
|
134
|
+
"""INSERT OR REPLACE INTO snapshots
|
|
135
|
+
(snapshot_id, execution_id, at_sequence, state, created_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
137
|
+
(snap_id, execution_id, at_sequence,
|
|
138
|
+
json.dumps(state, default=str),
|
|
139
|
+
datetime.now(timezone.utc).isoformat()),
|
|
140
|
+
)
|
|
141
|
+
self._conn.commit()
|
|
142
|
+
return snap_id
|
|
143
|
+
|
|
144
|
+
def load_snapshot(self, execution_id: str) -> dict[str, Any] | None:
|
|
145
|
+
try:
|
|
146
|
+
row = self._conn.execute(
|
|
147
|
+
"""SELECT state, at_sequence FROM snapshots
|
|
148
|
+
WHERE execution_id = ? ORDER BY at_sequence DESC LIMIT 1""",
|
|
149
|
+
(execution_id,),
|
|
150
|
+
).fetchone()
|
|
151
|
+
if row:
|
|
152
|
+
return {"state": json.loads(row["state"]), "at_sequence": row["at_sequence"]}
|
|
153
|
+
except sqlite3.OperationalError:
|
|
154
|
+
pass # table might not exist yet
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def count(self) -> int:
|
|
158
|
+
row = self._conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()
|
|
159
|
+
return row["c"] if row else 0
|
|
160
|
+
|
|
161
|
+
def close(self):
|
|
162
|
+
self._conn.close()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Built-in capabilities registry."""
|
|
2
|
+
from ..registry import CapabilityManifest
|
|
3
|
+
from .search import search
|
|
4
|
+
from .llm import llm
|
|
5
|
+
from .review import review
|
|
6
|
+
from .report import report
|
|
7
|
+
|
|
8
|
+
CAPABILITIES = {
|
|
9
|
+
"search": search,
|
|
10
|
+
"llm": llm,
|
|
11
|
+
"gather": review,
|
|
12
|
+
"review": review,
|
|
13
|
+
"report": report,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
CAPABILITY_MANIFESTS = [
|
|
17
|
+
CapabilityManifest(
|
|
18
|
+
task_type="search", display_name="Web Search",
|
|
19
|
+
description="Search the web for information",
|
|
20
|
+
version="0.1.0", tags=["research", "fetch"], fn=search,
|
|
21
|
+
),
|
|
22
|
+
CapabilityManifest(
|
|
23
|
+
task_type="llm", display_name="LLM Analysis",
|
|
24
|
+
description="Analyze data using a large language model",
|
|
25
|
+
version="0.1.0", tags=["analysis", "ai"], fn=llm,
|
|
26
|
+
),
|
|
27
|
+
CapabilityManifest(
|
|
28
|
+
task_type="gather", display_name="Result Gathering",
|
|
29
|
+
description="Aggregate and summarize task outputs",
|
|
30
|
+
version="0.1.0", tags=["aggregation"], fn=review,
|
|
31
|
+
),
|
|
32
|
+
CapabilityManifest(
|
|
33
|
+
task_type="review", display_name="Quality Review",
|
|
34
|
+
description="Review task outputs for quality and completeness",
|
|
35
|
+
version="0.1.0", tags=["quality"], fn=review,
|
|
36
|
+
),
|
|
37
|
+
CapabilityManifest(
|
|
38
|
+
task_type="report", display_name="Report Generation",
|
|
39
|
+
description="Compile final Markdown report",
|
|
40
|
+
version="0.1.0", tags=["output"], fn=report,
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
__all__ = ["search", "llm", "review", "report", "CAPABILITIES", "CAPABILITY_MANIFESTS"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LLM capability — calls OpenAI-compatible API, falls back to mock."""
|
|
2
|
+
from ..llm_executor import call_llm
|
|
3
|
+
from ..models import PlannedTask
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
def llm(task: PlannedTask, context: dict[str, Any]) -> str:
|
|
7
|
+
prompt = task.params.get("prompt", str(context))
|
|
8
|
+
system = task.params.get("system", "You are a helpful financial analyst.")
|
|
9
|
+
try:
|
|
10
|
+
return call_llm(prompt, system_prompt=system)
|
|
11
|
+
except (ConnectionError, ValueError) as e:
|
|
12
|
+
import logging
|
|
13
|
+
logging.warning("LLM API call failed, falling back to mock: %s", e)
|
|
14
|
+
return (
|
|
15
|
+
f"【LLM 分析结果 — API不可用,使用模拟数据】\n\n"
|
|
16
|
+
f"输入提示: {prompt[:100]}...\n\n"
|
|
17
|
+
"## 投资亮点\n"
|
|
18
|
+
"- 行业领先地位\n"
|
|
19
|
+
"- 强劲的营收增长\n\n"
|
|
20
|
+
"## 风险因素\n"
|
|
21
|
+
"- 市场竞争加剧\n"
|
|
22
|
+
"- 监管不确定性\n\n"
|
|
23
|
+
"## 综合评估\n"
|
|
24
|
+
"建议持续关注。(注: v1 模拟数据)"
|
|
25
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Report capability — compiles final Markdown output."""
|
|
2
|
+
from ..models import PlannedTask
|
|
3
|
+
from typing import Any
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
def report(task: PlannedTask, context: dict[str, Any]) -> str:
|
|
7
|
+
sections = task.params.get("sections", [])
|
|
8
|
+
lines = []
|
|
9
|
+
for sec in sections:
|
|
10
|
+
title = sec.get("title", "")
|
|
11
|
+
content = sec.get("content", "")
|
|
12
|
+
resolved = _resolve_template(content, context)
|
|
13
|
+
if title:
|
|
14
|
+
lines.append(f"## {title}")
|
|
15
|
+
lines.append(resolved)
|
|
16
|
+
return "\n\n".join(lines)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_template(template: str, context: dict[str, Any]) -> str:
|
|
20
|
+
"""Simple {{variable}} replacement from context."""
|
|
21
|
+
def _replacer(m):
|
|
22
|
+
key = m.group(1).strip()
|
|
23
|
+
val = context.get(key, m.group(0))
|
|
24
|
+
val = context.get(key, "")
|
|
25
|
+
return str(val) if val is not None else m.group(0)
|
|
26
|
+
return re.sub(r"\{\{(.+?)\}\}", _replacer, template)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Review capability — aggregates previous task outputs."""
|
|
2
|
+
from ..models import PlannedTask
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
def review(task: PlannedTask, context: dict[str, Any]) -> dict:
|
|
6
|
+
checks = task.params.get("checks", ["non_empty"])
|
|
7
|
+
outputs = {k: v for k, v in context.items() if isinstance(v, str)}
|
|
8
|
+
return {
|
|
9
|
+
"checks": checks,
|
|
10
|
+
"non_empty_count": sum(1 for v in outputs.values() if v.strip()),
|
|
11
|
+
"text": "\n\n".join(outputs.values()),
|
|
12
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Search capability — DuckDuckGo Instant Answer API with mock fallback.
|
|
3
|
+
|
|
4
|
+
Uses https://api.duckduckgo.com/?q=QUERY&format=json&no_html=1
|
|
5
|
+
No API key required. Falls back to mock data when the API is unreachable.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from ..models import PlannedTask
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
DDG_API_URL = "https://api.duckduckgo.com/"
|
|
15
|
+
|
|
16
|
+
def _fetch_ddg(query: str, sources: list[str] | None = None) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Query DuckDuckGo Instant Answer API.
|
|
19
|
+
Returns formatted markdown-like text.
|
|
20
|
+
"""
|
|
21
|
+
params = {
|
|
22
|
+
"q": query,
|
|
23
|
+
"format": "json",
|
|
24
|
+
"no_html": "1",
|
|
25
|
+
"skip_disambig": "1",
|
|
26
|
+
}
|
|
27
|
+
url = DDG_API_URL + "?" + urllib.parse.urlencode(params)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Agent-OS/1.0"})
|
|
31
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
32
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
33
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError):
|
|
34
|
+
return _mock_fallback(query)
|
|
35
|
+
|
|
36
|
+
return _format_ddg_response(data, query)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_ddg_response(data: dict, query: str) -> str:
|
|
40
|
+
"""Format DuckDuckGo API response into readable text."""
|
|
41
|
+
lines = [f"【Search Results】\nQuery: {query}\n"]
|
|
42
|
+
|
|
43
|
+
# Abstract (Answer)
|
|
44
|
+
abstract = data.get("Abstract", "") or ""
|
|
45
|
+
if abstract:
|
|
46
|
+
lines.append(f"▸ Summary: {abstract}\n")
|
|
47
|
+
|
|
48
|
+
# Definition
|
|
49
|
+
definition = data.get("Definition", "") or ""
|
|
50
|
+
if definition:
|
|
51
|
+
lines.append(f"▸ Definition: {definition}\n")
|
|
52
|
+
|
|
53
|
+
# Infobox
|
|
54
|
+
infobox = data.get("Infobox", None)
|
|
55
|
+
if infobox and isinstance(infobox, dict):
|
|
56
|
+
content = infobox.get("content", []) or []
|
|
57
|
+
if content:
|
|
58
|
+
lines.append("▸ Info:\n")
|
|
59
|
+
for item in content:
|
|
60
|
+
label = item.get("label", "") or ""
|
|
61
|
+
val = item.get("value", "") or ""
|
|
62
|
+
if label and val:
|
|
63
|
+
lines.append(f" • {label}: {val}\n")
|
|
64
|
+
|
|
65
|
+
# Related topics (first 3)
|
|
66
|
+
related = data.get("RelatedTopics", []) or []
|
|
67
|
+
if related:
|
|
68
|
+
lines.append("\n▸ Related Topics:\n")
|
|
69
|
+
count = 0
|
|
70
|
+
for topic in related:
|
|
71
|
+
if count >= 3:
|
|
72
|
+
break
|
|
73
|
+
text = topic.get("Text", "") or ""
|
|
74
|
+
if text:
|
|
75
|
+
lines.append(f" • {text}\n")
|
|
76
|
+
count += 1
|
|
77
|
+
|
|
78
|
+
# Results from the API
|
|
79
|
+
results = data.get("Results", []) or []
|
|
80
|
+
if results:
|
|
81
|
+
lines.append("\n▸ Results:\n")
|
|
82
|
+
for i, r in enumerate(results[:5], 1):
|
|
83
|
+
text = r.get("Text", "") or ""
|
|
84
|
+
first_url = r.get("FirstURL", "") or ""
|
|
85
|
+
if text:
|
|
86
|
+
lines.append(f" {i}. {text}\n")
|
|
87
|
+
if first_url:
|
|
88
|
+
lines.append(f" {first_url}\n")
|
|
89
|
+
|
|
90
|
+
if not abstract and not definition and not infobox and not related and not results:
|
|
91
|
+
return _mock_fallback(query)
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines).strip()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mock_fallback(query: str) -> str:
|
|
97
|
+
"""Mock fallback when DuckDuckGo API is unavailable."""
|
|
98
|
+
return (
|
|
99
|
+
f"【Search Results】\n"
|
|
100
|
+
f"Query: {query}\n\n"
|
|
101
|
+
f"1. {query} — latest developments summary\n"
|
|
102
|
+
f"2. {query} — industry analysis\n"
|
|
103
|
+
f"3. Related market data\n\n"
|
|
104
|
+
"(Note: DuckDuckGo API was unavailable; showing fallback mock data)"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def search(task: PlannedTask, context: dict[str, Any]) -> str:
|
|
109
|
+
query = task.params.get("query", "")
|
|
110
|
+
sources = task.params.get("sources", None)
|
|
111
|
+
return _fetch_ddg(query, sources)
|