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 ADDED
@@ -0,0 +1,3 @@
1
+ """IntentOS P1 Runtime Kernel — rapid prototype."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -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)