ag2-sparrow 0.1.0__tar.gz
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.
- ag2_sparrow-0.1.0/PKG-INFO +54 -0
- ag2_sparrow-0.1.0/README.md +38 -0
- ag2_sparrow-0.1.0/ag2_sparrow/__init__.py +12 -0
- ag2_sparrow-0.1.0/ag2_sparrow/__main__.py +4 -0
- ag2_sparrow-0.1.0/ag2_sparrow/local_task_protocol.py +495 -0
- ag2_sparrow-0.1.0/ag2_sparrow/remote_gateway_bridge.py +895 -0
- ag2_sparrow-0.1.0/ag2_sparrow/result_markers.py +287 -0
- ag2_sparrow-0.1.0/ag2_sparrow/send_allowlist.py +106 -0
- ag2_sparrow-0.1.0/ag2_sparrow/sutando_config.py +712 -0
- ag2_sparrow-0.1.0/ag2_sparrow/task_archive.py +32 -0
- ag2_sparrow-0.1.0/ag2_sparrow/util_paths.py +344 -0
- ag2_sparrow-0.1.0/ag2_sparrow/workspace_default.py +498 -0
- ag2_sparrow-0.1.0/ag2_sparrow.egg-info/PKG-INFO +54 -0
- ag2_sparrow-0.1.0/ag2_sparrow.egg-info/SOURCES.txt +17 -0
- ag2_sparrow-0.1.0/ag2_sparrow.egg-info/dependency_links.txt +1 -0
- ag2_sparrow-0.1.0/ag2_sparrow.egg-info/entry_points.txt +2 -0
- ag2_sparrow-0.1.0/ag2_sparrow.egg-info/top_level.txt +1 -0
- ag2_sparrow-0.1.0/pyproject.toml +31 -0
- ag2_sparrow-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ag2-sparrow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transport client that connects a local agent to AG2 Space: long-polls the task gateway for your agent's tasks and posts results back.
|
|
5
|
+
Author: AG2 Space
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://ag2.space
|
|
8
|
+
Project-URL: Source, https://github.com/sonichi/sutando
|
|
9
|
+
Keywords: ag2,agent,relay,gateway,matrix
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ag2-sparrow
|
|
18
|
+
|
|
19
|
+
*The AG2 Space task relay client.*
|
|
20
|
+
|
|
21
|
+
Transport client that connects a **local agent** to **AG2 Space**.
|
|
22
|
+
|
|
23
|
+
It long-polls the AG2 Space task gateway for *your* agent's tasks (identified by
|
|
24
|
+
its relay token), drops each into a workspace, and posts results back. It carries
|
|
25
|
+
no agent logic — pair it with a worker (e.g. [`agent-connect`](https://github.com/ag2-space/agent-connect))
|
|
26
|
+
that turns each task into an agent run.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pipx install ag2-sparrow # or: pip install ag2-sparrow
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
REMOTE_TASK_TOKEN=<your relay token from the AG2 Space Agent Portal> \
|
|
38
|
+
REMOTE_TASK_URL=https://chat.ag2.space/relay \
|
|
39
|
+
ag2-sparrow
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The token is your AG2 Space **identity** — not a model API key. Your agent runs
|
|
43
|
+
locally with its own credentials; only tasks and results flow through AG2 Space.
|
|
44
|
+
|
|
45
|
+
## Single source of truth
|
|
46
|
+
|
|
47
|
+
These modules are the canonical AG2 Space relay client, kept in lockstep with
|
|
48
|
+
[`sonichi/sutando`](https://github.com/sonichi/sutando) `src/`. They are copied
|
|
49
|
+
verbatim (never forked) via `tools/sync_from_src.py`; a CI drift-check fails if
|
|
50
|
+
the package diverges from the source. Zero third-party runtime dependencies.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ag2-sparrow
|
|
2
|
+
|
|
3
|
+
*The AG2 Space task relay client.*
|
|
4
|
+
|
|
5
|
+
Transport client that connects a **local agent** to **AG2 Space**.
|
|
6
|
+
|
|
7
|
+
It long-polls the AG2 Space task gateway for *your* agent's tasks (identified by
|
|
8
|
+
its relay token), drops each into a workspace, and posts results back. It carries
|
|
9
|
+
no agent logic — pair it with a worker (e.g. [`agent-connect`](https://github.com/ag2-space/agent-connect))
|
|
10
|
+
that turns each task into an agent run.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pipx install ag2-sparrow # or: pip install ag2-sparrow
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Run
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
REMOTE_TASK_TOKEN=<your relay token from the AG2 Space Agent Portal> \
|
|
22
|
+
REMOTE_TASK_URL=https://chat.ag2.space/relay \
|
|
23
|
+
ag2-sparrow
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The token is your AG2 Space **identity** — not a model API key. Your agent runs
|
|
27
|
+
locally with its own credentials; only tasks and results flow through AG2 Space.
|
|
28
|
+
|
|
29
|
+
## Single source of truth
|
|
30
|
+
|
|
31
|
+
These modules are the canonical AG2 Space relay client, kept in lockstep with
|
|
32
|
+
[`sonichi/sutando`](https://github.com/sonichi/sutando) `src/`. They are copied
|
|
33
|
+
verbatim (never forked) via `tools/sync_from_src.py`; a CI drift-check fails if
|
|
34
|
+
the package diverges from the source. Zero third-party runtime dependencies.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""ag2-sparrow — connect a local agent to AG2 Space (the AG2 task relay client).
|
|
2
|
+
|
|
3
|
+
Transport only: long-polls the AG2 Space task gateway for THIS agent's tasks
|
|
4
|
+
(identified by its relay token), drops each into a workspace, and posts results
|
|
5
|
+
back. No agent logic — a worker (e.g. ag2-space/agent-connect) turns each task
|
|
6
|
+
into an agent run.
|
|
7
|
+
|
|
8
|
+
The modules here are the canonical AG2 Space relay client, kept in lockstep with
|
|
9
|
+
sonichi/sutando `src/` via tools/sync_from_src.py (a drift-check test fails CI if
|
|
10
|
+
they diverge — single source of truth, no hand-maintained fork).
|
|
11
|
+
"""
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Task Protocol — read-side reference implementation.
|
|
3
|
+
|
|
4
|
+
Interaction-planes refactor, step 3 (read side). The durable local execution
|
|
5
|
+
boundary: this module names the schema of `tasks/*.txt` files and provides the
|
|
6
|
+
canonical pure functions for reading them. It consolidates parsing that today
|
|
7
|
+
is hand-rolled per consumer (task_priority.py, task-bridge's `_isVoiceTask`,
|
|
8
|
+
each bridge's header scan) so new code imports ONE definition. Writers are
|
|
9
|
+
deliberately untouched in this phase — the write-side switch happens per
|
|
10
|
+
bridge, later, with byte-identical golden tests.
|
|
11
|
+
|
|
12
|
+
The result-body half of the protocol already lives in `src/result_markers.py`
|
|
13
|
+
(#873) and stays there; this module is the TASK-file half plus shared schema
|
|
14
|
+
constants.
|
|
15
|
+
|
|
16
|
+
R1 invariant (design doc §6): everything here is stdlib-only, no network, no
|
|
17
|
+
daemon, no lock service — the last-resort producers (health-check --emit-task,
|
|
18
|
+
Sutando.app context-drop) must keep working under total daemon death, and the
|
|
19
|
+
future write side of this module inherits that constraint.
|
|
20
|
+
|
|
21
|
+
## The two header shapes (and why there are two parsers)
|
|
22
|
+
|
|
23
|
+
Producers serialize headers in two shapes with three distinct trust
|
|
24
|
+
mechanisms — established by surveying 3.4k archived task files plus every
|
|
25
|
+
live writer (2026-07-06):
|
|
26
|
+
|
|
27
|
+
**task-last** (task-bridge.ts chat/voice/context-drop, agent-api `/task`):
|
|
28
|
+
every header precedes the `task:` line; the body after it is untrusted
|
|
29
|
+
multi-line content. Ordering IS the trust mechanism — consumers stop
|
|
30
|
+
header-scanning at the first `task:` line, or a body containing
|
|
31
|
+
`\naccess_tier: owner` forges headers (the PR #982 injection, re-flagged in
|
|
32
|
+
#1035). Use `parse_task_headers()`.
|
|
33
|
+
|
|
34
|
+
**task-mid** (all Python bridges: discord/slack/telegram/gateway, plus
|
|
35
|
+
health-check and github-webhook): `task:` lands mid-file and real headers
|
|
36
|
+
(source, channel_id, …) follow it. Safe only because the writer neutralizes
|
|
37
|
+
the body by one of two mechanisms: newline-stripping every value
|
|
38
|
+
(`_one_line` in the gateway, the sanitizer in github-webhook) or defanging
|
|
39
|
+
header-like body lines with a ZWSP prefix (`task_body_guard.
|
|
40
|
+
confine_user_content` in discord/telegram). Use
|
|
41
|
+
`parse_task_headers_trusted()` — full scan, LAST occurrence wins, which the
|
|
42
|
+
gateway's tier defense depends on (its locally-decided `access_tier` is
|
|
43
|
+
written last to beat anything the remote side claimed).
|
|
44
|
+
|
|
45
|
+
Pick the parser by writer, never by sniffing content. The safe default for
|
|
46
|
+
a file of unknown provenance is `parse_task_headers()` — it under-reads
|
|
47
|
+
task-mid files rather than over-trusting task-last ones. That under-read is
|
|
48
|
+
real: `parse_priority_from_text` (stop-at-`task:`) has never seen a
|
|
49
|
+
bridge-written `priority:` field. The write-side phase converges writers on
|
|
50
|
+
task-last; until then both parsers exist and are named for their trust model.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
import re
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Iterable
|
|
60
|
+
|
|
61
|
+
# ── Schema constants ─────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
# Interaction-plane vocabulary (step 1, PR #1953). Producers stamp exactly one
|
|
64
|
+
# of these next to `source:`; the remote-gateway bridge whitelists inbound
|
|
65
|
+
# values against this set (unknown → "message").
|
|
66
|
+
INTERACTION_TYPES = frozenset({
|
|
67
|
+
"message", "realtime_audio", "realtime_video",
|
|
68
|
+
"tool_initiated", "system_event", "self_reflective",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# Content-modality vocabulary (interaction-model 4D, step 1.5). The modalities
|
|
72
|
+
# a task's payload spans — orthogonal to interaction_type (which names the
|
|
73
|
+
# *mode*, not the media). A text-only DM is {"text"}; a photo with a caption is
|
|
74
|
+
# {"text", "image"}; a voice note is {"audio"}. Producers stamp a comma-joined
|
|
75
|
+
# subset next to `interaction_type`; readers whitelist against this set
|
|
76
|
+
# (unknown token dropped), the same discipline as the interaction_type gateway
|
|
77
|
+
# whitelist.
|
|
78
|
+
CONTENT_MODALITIES = frozenset({"text", "image", "audio", "video", "file"})
|
|
79
|
+
|
|
80
|
+
# Media-form vocabulary — the load-bearing routing axis of the 4D model:
|
|
81
|
+
# `attachment` = a discrete, already-bounded object delivered alongside a
|
|
82
|
+
# message (a photo, a PDF, a voice note) → lands in the task file as an
|
|
83
|
+
# AttachmentRef the Core fetches + analyzes. `live_stream` = a continuous
|
|
84
|
+
# real-time media plane (a call's audio, a screen share) → does NOT belong in
|
|
85
|
+
# a task file; it is owned by the LiveAgentRuntime. A missing header defaults
|
|
86
|
+
# to `attachment`: messaging is the default plane, and `live_stream` is the
|
|
87
|
+
# explicit opt-out that routes a payload away from the task-file path.
|
|
88
|
+
MEDIA_FORMS = frozenset({"attachment", "live_stream"})
|
|
89
|
+
|
|
90
|
+
# Task priority enum (src/task_priority.py is the behavior owner; these are
|
|
91
|
+
# the schema names). Consumer semantics: highest first, mtime FIFO tiebreak.
|
|
92
|
+
PRIORITIES = ("urgent", "normal", "low")
|
|
93
|
+
|
|
94
|
+
# Access tiers (CLAUDE.md access-control sections). `owner` is full
|
|
95
|
+
# processing; team/other are sandboxed. A missing header reads as owner for
|
|
96
|
+
# legacy local files — that default belongs to consumers, not this module.
|
|
97
|
+
ACCESS_TIERS = ("owner", "team", "other")
|
|
98
|
+
|
|
99
|
+
# The header vocabulary: every key observed in the real archive corpus
|
|
100
|
+
# (3,401 files, 2026-07-06) plus the live writers' full sets. This list is
|
|
101
|
+
# ENFORCED in two places that must stay in lockstep (Codex P2 on PR #1954):
|
|
102
|
+
# - the parsers below promote ONLY these keys to headers (an unknown
|
|
103
|
+
# `key: value` line stays in the body — so junk like transcript dialogue
|
|
104
|
+
# `Caller: hi` never becomes metadata), and
|
|
105
|
+
# - task_body_guard.confine_user_content defangs exactly these keys in
|
|
106
|
+
# untrusted bodies (it imports this list), so no key a parser would trust
|
|
107
|
+
# can survive undefanged in user-supplied content.
|
|
108
|
+
# Adding a producer header = add it here; the guard follows automatically.
|
|
109
|
+
KNOWN_HEADER_KEYS = (
|
|
110
|
+
"id", "timestamp", "task", "source", "access_tier", "user_id",
|
|
111
|
+
"channel_id", "priority", "interaction_type", "source_message_id",
|
|
112
|
+
"channel_name", "guild_name", "attempts", "sender_name", "room_name",
|
|
113
|
+
"parent_message_id", "reminder", "author_name", "author_id", "chat_id",
|
|
114
|
+
"thread_ts", "reply_to_event", "reply_to_me", "callSid", "caller",
|
|
115
|
+
"from", "call_sid", "hint", "instructions", "transcript",
|
|
116
|
+
# interaction-model 4D, step 1.5 — structured media metadata. Listing them
|
|
117
|
+
# here promotes them to headers AND (via the guard's shared import) defangs
|
|
118
|
+
# them in untrusted bodies, so a forged `attachments:` body line can never
|
|
119
|
+
# smuggle a fetch locator past the parser.
|
|
120
|
+
"content_modalities", "media_form", "attachments",
|
|
121
|
+
# Platform-signed metadata pointer (one-line JSON object): a signed
|
|
122
|
+
# reference to the delivering platform's canonical agent operating card,
|
|
123
|
+
# verifiable offline-of-the-task against the platform's well-known key
|
|
124
|
+
# (skills/agent-room-ops/verify_platform_card.py). Header status means a
|
|
125
|
+
# trusted bridge wrote it; the guard defangs a forged `platform_card:`
|
|
126
|
+
# body line the same as `attachments:`.
|
|
127
|
+
"platform_card",
|
|
128
|
+
)
|
|
129
|
+
_KNOWN_KEY_SET = frozenset(KNOWN_HEADER_KEYS)
|
|
130
|
+
|
|
131
|
+
# Task-id shape: `task-<slug>` where slug is dash-separated [a-z0-9] segments
|
|
132
|
+
# (task-1783..., task-chat-1783..., task-phone-..., task-summary-...,
|
|
133
|
+
# task-gh-..., task-health-...). Mirrors the gateway bridge's `_valid_tid`
|
|
134
|
+
# defense: ids become filenames, so the charset is the path-traversal guard.
|
|
135
|
+
TASK_ID_RE = re.compile(r"^task-[A-Za-z0-9][A-Za-z0-9-]{0,120}$")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def valid_task_id(tid: str) -> bool:
|
|
139
|
+
"""True iff `tid` is a well-formed task id, safe to embed in a filename.
|
|
140
|
+
|
|
141
|
+
Rejects path separators, dots, whitespace, and empty/oversized ids — the
|
|
142
|
+
id is used as `tasks/<tid>.txt` and `results/<tid>.txt`, so this is the
|
|
143
|
+
single traversal gate for readers that accept ids from message content
|
|
144
|
+
(e.g. `[deduped: <tid>]` holders).
|
|
145
|
+
"""
|
|
146
|
+
return bool(TASK_ID_RE.match(tid or ""))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── Header parsing ───────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
_HEADER_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):[ \t]?(.*)$")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class TaskHeaders:
|
|
156
|
+
"""Parsed view of a task file. `headers` preserves the parser's trust
|
|
157
|
+
rule (see module docstring).
|
|
158
|
+
|
|
159
|
+
`body` semantics differ BY PARSER — this is deliberate and load-bearing:
|
|
160
|
+
- `parse_task_headers` (task-last): the full work item — the `task:`
|
|
161
|
+
line's content plus every line after it, verbatim.
|
|
162
|
+
- `parse_task_headers_trusted` / `_lenient` (task-mid): the `task:`
|
|
163
|
+
line's content plus every subsequent NON-vocabulary line. Vocabulary
|
|
164
|
+
header lines (KNOWN_HEADER_KEYS) after `task:` are promoted to
|
|
165
|
+
`headers` and excluded from body; everything else — health-check
|
|
166
|
+
failure bullets, phone transcript dialogue, unknown `key:`-shaped
|
|
167
|
+
lines — stays in body. Every post-`task:` line lands in exactly one
|
|
168
|
+
of headers/body (no silent loss; Codex P2s on PR #1954). For the
|
|
169
|
+
byte-verbatim work item including trailing headers, use `task_body()`.
|
|
170
|
+
"""
|
|
171
|
+
headers: dict = field(default_factory=dict)
|
|
172
|
+
body: str = ""
|
|
173
|
+
|
|
174
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
175
|
+
return self.headers.get(key, default)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def task_body(text: str) -> str:
|
|
179
|
+
"""The complete work item of a task file, shape-independent: everything
|
|
180
|
+
from the first `task:` line onward, verbatim, with only the `task:`
|
|
181
|
+
prefix stripped from that first line. Never drops a line — for task-mid
|
|
182
|
+
files this includes trailing headers, which is the honest trade: a reader
|
|
183
|
+
that wants clean headers uses the parsers; a reader that wants the full
|
|
184
|
+
work item (health bullets, phone transcript, meeting instructions) uses
|
|
185
|
+
this and must tolerate header lines inside it."""
|
|
186
|
+
lines = text.split("\n")
|
|
187
|
+
for i, line in enumerate(lines):
|
|
188
|
+
if line.startswith("task:"):
|
|
189
|
+
return "\n".join([line[len("task:"):].lstrip()] + lines[i + 1:])
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_task_headers(text: str) -> TaskHeaders:
|
|
194
|
+
"""Parse a **task-last** file: collect `key: value` lines strictly BEFORE
|
|
195
|
+
the first `task:` line; everything from `task:` onward is untrusted body.
|
|
196
|
+
|
|
197
|
+
This is the safe default parser — the delimiter rule that keeps a
|
|
198
|
+
user-supplied body from forging headers (PR #982 / #1035). First
|
|
199
|
+
occurrence of a key wins (a duplicate later is more likely forged than
|
|
200
|
+
corrective). The body includes the `task:` line's own content.
|
|
201
|
+
"""
|
|
202
|
+
headers: dict = {}
|
|
203
|
+
body_lines: list[str] = []
|
|
204
|
+
in_body = False
|
|
205
|
+
for line in text.split("\n"):
|
|
206
|
+
if in_body:
|
|
207
|
+
body_lines.append(line)
|
|
208
|
+
continue
|
|
209
|
+
if line.startswith("task:"):
|
|
210
|
+
in_body = True
|
|
211
|
+
body_lines.append(line[len("task:"):].lstrip())
|
|
212
|
+
continue
|
|
213
|
+
m = _HEADER_LINE_RE.match(line)
|
|
214
|
+
if m and m.group(1) in _KNOWN_KEY_SET:
|
|
215
|
+
headers.setdefault(m.group(1), m.group(2))
|
|
216
|
+
# Unknown-key and non-matching lines before task: are tolerated and
|
|
217
|
+
# skipped — real archive files contain blank lines and free-text
|
|
218
|
+
# hint blocks; only vocabulary keys become metadata.
|
|
219
|
+
return TaskHeaders(headers=headers, body="\n".join(body_lines))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_task_headers_lenient(text: str) -> TaskHeaders:
|
|
223
|
+
"""Parse across ALL lines, FIRST occurrence of each key wins.
|
|
224
|
+
|
|
225
|
+
The shape-union reader: producers' field order has changed across eras
|
|
226
|
+
(May-2026 voice tasks were task-mid; today's are task-last), so consumers
|
|
227
|
+
that must classify files of any age — e.g. discord-bridge's DM-fallback
|
|
228
|
+
`source:` probe — need a scan that finds headers wherever that era's
|
|
229
|
+
writer put them. First-wins resists trailing-body forgery when the real
|
|
230
|
+
header exists; it does NOT protect a file that legitimately lacks the
|
|
231
|
+
probed key (a body line can then supply it). That spoofability predates
|
|
232
|
+
this module — hardening it means changing verdicts for historical shapes
|
|
233
|
+
and is deliberately out of scope for the read-side refactor.
|
|
234
|
+
"""
|
|
235
|
+
return _parse_task_mid(text, last_wins=False)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_task_headers_trusted(text: str) -> TaskHeaders:
|
|
239
|
+
"""Parse a **task-mid** file from a trusted writer (remote-gateway-bridge):
|
|
240
|
+
scan ALL lines for `key: value`, LAST occurrence wins.
|
|
241
|
+
|
|
242
|
+
Only valid when the writer is known to newline-strip every value — that
|
|
243
|
+
property is what makes the full scan injection-free, and last-wins is
|
|
244
|
+
load-bearing: the gateway writes its locally-decided `access_tier` last
|
|
245
|
+
precisely so it beats anything the remote side claimed earlier in the
|
|
246
|
+
file. Applying this parser to a task-last file would let the body forge
|
|
247
|
+
headers; pick the parser by writer, not by content sniffing.
|
|
248
|
+
"""
|
|
249
|
+
return _parse_task_mid(text, last_wins=True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_task_mid(text: str, last_wins: bool) -> TaskHeaders:
|
|
253
|
+
"""Shared task-mid scan. Headers = vocabulary keys only
|
|
254
|
+
(KNOWN_HEADER_KEYS); body = the `task:` line's content plus every
|
|
255
|
+
subsequent NON-vocabulary line — so continuation content (health-check
|
|
256
|
+
failure bullets, phone transcript dialogue, skill-hint blocks) is
|
|
257
|
+
preserved losslessly in body while trailing real headers are excluded.
|
|
258
|
+
Every input line after `task:` lands in exactly one of headers/body
|
|
259
|
+
(fidelity is asserted over the full archive corpus in the golden test).
|
|
260
|
+
An unknown `key: value` shape (`Caller: hi`) is body, never metadata."""
|
|
261
|
+
headers: dict = {}
|
|
262
|
+
body_lines: list[str] = []
|
|
263
|
+
in_body = False
|
|
264
|
+
for line in text.split("\n"):
|
|
265
|
+
if not in_body and line.startswith("task:"):
|
|
266
|
+
in_body = True
|
|
267
|
+
body_lines.append(line[len("task:"):].lstrip())
|
|
268
|
+
continue
|
|
269
|
+
m = _HEADER_LINE_RE.match(line)
|
|
270
|
+
if m and m.group(1) in _KNOWN_KEY_SET:
|
|
271
|
+
if last_wins:
|
|
272
|
+
headers[m.group(1)] = m.group(2)
|
|
273
|
+
else:
|
|
274
|
+
headers.setdefault(m.group(1), m.group(2))
|
|
275
|
+
elif in_body:
|
|
276
|
+
body_lines.append(line)
|
|
277
|
+
# Trailing blank lines are file-termination artifacts, not content —
|
|
278
|
+
# interior blank lines are preserved.
|
|
279
|
+
while body_lines and body_lines[-1] == "":
|
|
280
|
+
body_lines.pop()
|
|
281
|
+
return TaskHeaders(headers=headers, body="\n".join(body_lines))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── Media attachments (interaction-model 4D, step 1.5) ───────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class AttachmentRef:
|
|
289
|
+
"""One discrete media object carried by a task, source-independent.
|
|
290
|
+
|
|
291
|
+
The unified descriptor every bridge normalizes its native attachment into
|
|
292
|
+
(Discord CDN url, Slack file, Telegram file_id, Matrix mxc://, or an
|
|
293
|
+
already-downloaded local path) so the Core sees ONE shape regardless of
|
|
294
|
+
surface. `locator` is the source-specific pointer to the bytes; the
|
|
295
|
+
safe-fetch policy (bridge/relay side, a later slice) resolves a remote
|
|
296
|
+
locator to a local path and may rewrite `locator` in place once fetched.
|
|
297
|
+
|
|
298
|
+
Deliberately metadata-only: no bytes, no file handles (R1 stdlib-only, and
|
|
299
|
+
a task file must stay a small text record). `size`/`sha256` let a consumer
|
|
300
|
+
enforce a limit and dedupe *before* fetching. Every field but `locator` is
|
|
301
|
+
optional — a ref with nothing to point at is meaningless, so an element
|
|
302
|
+
lacking a locator is dropped on both encode and decode.
|
|
303
|
+
"""
|
|
304
|
+
locator: str
|
|
305
|
+
id: str = ""
|
|
306
|
+
mime: str = ""
|
|
307
|
+
filename: str = ""
|
|
308
|
+
size: int = 0
|
|
309
|
+
sha256: str = ""
|
|
310
|
+
expiry: str = "" # ISO-8601 UTC; "" = no expiry / already-local
|
|
311
|
+
|
|
312
|
+
def as_dict(self) -> dict:
|
|
313
|
+
"""Compact dict for JSON serialization — omits empty/zero fields so the
|
|
314
|
+
header line stays small and round-trips back to an equal ref."""
|
|
315
|
+
d: dict = {"locator": self.locator}
|
|
316
|
+
for k in ("id", "mime", "filename", "sha256", "expiry"):
|
|
317
|
+
v = getattr(self, k)
|
|
318
|
+
if v:
|
|
319
|
+
d[k] = v
|
|
320
|
+
if self.size:
|
|
321
|
+
d["size"] = self.size
|
|
322
|
+
return d
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def parse_content_modalities(headers: dict) -> frozenset:
|
|
326
|
+
"""The whitelisted set of content modalities from a parsed header dict.
|
|
327
|
+
|
|
328
|
+
Reads the comma-joined `content_modalities` value, lower-cases + trims each
|
|
329
|
+
token, and drops anything outside CONTENT_MODALITIES — an unknown token is
|
|
330
|
+
noise, never a new modality (same rule as the interaction_type whitelist).
|
|
331
|
+
Missing/empty → empty set; a text-only task need not stamp `{"text"}`."""
|
|
332
|
+
raw = (headers or {}).get("content_modalities") or ""
|
|
333
|
+
return frozenset(
|
|
334
|
+
t for t in (tok.strip().lower() for tok in raw.split(",")) if t in CONTENT_MODALITIES
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def parse_media_form(headers: dict) -> str:
|
|
339
|
+
"""The task's media form: `attachment` (default) or `live_stream`.
|
|
340
|
+
|
|
341
|
+
Whitelists the `media_form` value; anything unknown or missing collapses to
|
|
342
|
+
`attachment`, the safe default that routes the payload through the normal
|
|
343
|
+
task-file path rather than mis-claiming a LiveAgentRuntime stream."""
|
|
344
|
+
v = ((headers or {}).get("media_form") or "").strip().lower()
|
|
345
|
+
return v if v in MEDIA_FORMS else "attachment"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def parse_attachments(headers: dict) -> list["AttachmentRef"]:
|
|
349
|
+
"""Decode the `attachments:` header — a one-line JSON array of objects —
|
|
350
|
+
into AttachmentRefs.
|
|
351
|
+
|
|
352
|
+
Tolerant by contract: a malformed value, a non-list payload, a non-object
|
|
353
|
+
element, or an element with no `locator` is skipped, never raised. A bad
|
|
354
|
+
attachments header must not block reading the rest of the task (the same
|
|
355
|
+
never-block principle as the audit sink). Missing header → []."""
|
|
356
|
+
raw = (headers or {}).get("attachments")
|
|
357
|
+
if not raw:
|
|
358
|
+
return []
|
|
359
|
+
try:
|
|
360
|
+
arr = json.loads(raw)
|
|
361
|
+
except (ValueError, TypeError):
|
|
362
|
+
return []
|
|
363
|
+
if not isinstance(arr, list):
|
|
364
|
+
return []
|
|
365
|
+
refs: list[AttachmentRef] = []
|
|
366
|
+
for el in arr:
|
|
367
|
+
if not isinstance(el, dict):
|
|
368
|
+
continue
|
|
369
|
+
locator = el.get("locator")
|
|
370
|
+
if not locator or not isinstance(locator, str):
|
|
371
|
+
continue
|
|
372
|
+
size = el.get("size", 0)
|
|
373
|
+
if isinstance(size, bool):
|
|
374
|
+
size = 0 # a JSON bool is not a byte count (int(True) == 1 would lie)
|
|
375
|
+
elif not isinstance(size, int):
|
|
376
|
+
try:
|
|
377
|
+
size = int(size)
|
|
378
|
+
except (ValueError, TypeError):
|
|
379
|
+
size = 0
|
|
380
|
+
if size < 0:
|
|
381
|
+
size = 0 # a negative byte count is nonsense and would slip past a
|
|
382
|
+
# `ref.size and ref.size > max_bytes` cap check — normalize
|
|
383
|
+
# it to 0 (unknown) so limit enforcement stays sound.
|
|
384
|
+
refs.append(AttachmentRef(
|
|
385
|
+
locator=locator,
|
|
386
|
+
id=str(el.get("id", "")),
|
|
387
|
+
mime=str(el.get("mime", "")),
|
|
388
|
+
filename=str(el.get("filename", "")),
|
|
389
|
+
size=size,
|
|
390
|
+
sha256=str(el.get("sha256", "")),
|
|
391
|
+
expiry=str(el.get("expiry", "")),
|
|
392
|
+
))
|
|
393
|
+
return refs
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def format_attachments(refs: Iterable["AttachmentRef"]) -> str:
|
|
397
|
+
"""Encode AttachmentRefs into the one-line JSON value of an `attachments:`
|
|
398
|
+
header — the write-side inverse of parse_attachments.
|
|
399
|
+
|
|
400
|
+
Guaranteed single-line (json escapes any embedded newline in a field), so
|
|
401
|
+
it is safe for both task-last single-line values and the task-mid
|
|
402
|
+
newline-stripping writers. Refs without a locator are dropped (they'd be
|
|
403
|
+
unparseable on read, so encoding them would not round-trip)."""
|
|
404
|
+
payload = [r.as_dict() for r in refs if r.locator]
|
|
405
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def modality_for_mime(mime: str) -> str:
|
|
409
|
+
"""Map an attachment MIME type to a content modality (CONTENT_MODALITIES):
|
|
410
|
+
image/audio/video by top-level type, everything else (pdf, zip, docx,
|
|
411
|
+
unknown) → `file`. The shared home for the mapping each bridge needs when it
|
|
412
|
+
stamps `content_modalities` — promoted here once a third bridge required it
|
|
413
|
+
(discord/telegram carried private copies first)."""
|
|
414
|
+
m = (mime or "").lower()
|
|
415
|
+
if m.startswith("image/"):
|
|
416
|
+
return "image"
|
|
417
|
+
if m.startswith("audio/"):
|
|
418
|
+
return "audio"
|
|
419
|
+
if m.startswith("video/"):
|
|
420
|
+
return "video"
|
|
421
|
+
return "file"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def media_attachment_headers(attachment_refs: Iterable["AttachmentRef"], has_text: bool) -> str:
|
|
425
|
+
"""Build the `content_modalities`/`media_form`/`attachments` header block a
|
|
426
|
+
bridge stamps on a task file when a message carried attachments (step 1.5).
|
|
427
|
+
Returns "" when there are no refs — so a text-only task's headers are
|
|
428
|
+
unchanged.
|
|
429
|
+
|
|
430
|
+
`content_modalities` = `text` (iff `has_text`) plus one modality per ref
|
|
431
|
+
mime; `media_form` = `attachment` (these are discrete objects — a live
|
|
432
|
+
stream is never emitted through the messaging task path); `attachments` =
|
|
433
|
+
the one-line JSON from `format_attachments`. The block is newline-terminated
|
|
434
|
+
so it composes directly into a writer's header f-string. Pure — a bridge can
|
|
435
|
+
unit-test its write path without a live message object."""
|
|
436
|
+
refs = [r for r in attachment_refs if r.locator]
|
|
437
|
+
if not refs:
|
|
438
|
+
return ""
|
|
439
|
+
mods = set()
|
|
440
|
+
if has_text:
|
|
441
|
+
mods.add("text")
|
|
442
|
+
for r in refs:
|
|
443
|
+
mods.add(modality_for_mime(r.mime))
|
|
444
|
+
return (
|
|
445
|
+
f"content_modalities: {','.join(sorted(mods))}\n"
|
|
446
|
+
f"media_form: attachment\n"
|
|
447
|
+
f"attachments: {format_attachments(refs)}\n"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ── Archive rules ────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
_MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def archive_month_dir(base: Path, iso_timestamp: str) -> Path:
|
|
457
|
+
"""Month-partitioned archive dir for a given ISO timestamp: the layout
|
|
458
|
+
task-bridge.ts introduced in PR #591 (`archive/YYYY-MM/`). The month
|
|
459
|
+
comes from the supplied timestamp, not the wall clock, so writers and
|
|
460
|
+
tests are deterministic around month boundaries."""
|
|
461
|
+
return base / "archive" / iso_timestamp[:7]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def find_archived_task(tasks_dir: Path, task_id: str) -> Path | None:
|
|
465
|
+
"""Locate a task file across the live dir, the legacy flat archive, and
|
|
466
|
+
the month-partitioned archive — the same candidate set task-bridge's
|
|
467
|
+
`_isVoiceTask` walks. Returns the first existing path or None. Rejects
|
|
468
|
+
malformed ids rather than globbing with them (traversal gate)."""
|
|
469
|
+
if not valid_task_id(task_id):
|
|
470
|
+
return None
|
|
471
|
+
fname = f"{task_id}.txt"
|
|
472
|
+
candidates = [tasks_dir / fname, tasks_dir / "processed" / fname,
|
|
473
|
+
tasks_dir / "archive" / fname]
|
|
474
|
+
archive_root = tasks_dir / "archive"
|
|
475
|
+
if archive_root.is_dir():
|
|
476
|
+
for entry in sorted(archive_root.iterdir()):
|
|
477
|
+
if entry.is_dir() and _MONTH_DIR_RE.match(entry.name):
|
|
478
|
+
candidates.append(entry / fname)
|
|
479
|
+
for p in candidates:
|
|
480
|
+
if p.exists():
|
|
481
|
+
return p
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def iter_archived_tasks(tasks_dir: Path) -> Iterable[Path]:
|
|
486
|
+
"""Yield every archived task file (flat legacy + month-partitioned),
|
|
487
|
+
for corpus sweeps and golden tests."""
|
|
488
|
+
archive_root = tasks_dir / "archive"
|
|
489
|
+
if not archive_root.is_dir():
|
|
490
|
+
return
|
|
491
|
+
for p in sorted(archive_root.glob("*.txt")):
|
|
492
|
+
yield p
|
|
493
|
+
for entry in sorted(archive_root.iterdir()):
|
|
494
|
+
if entry.is_dir() and _MONTH_DIR_RE.match(entry.name):
|
|
495
|
+
yield from sorted(entry.glob("*.txt"))
|