agent-first-data 0.16.0__tar.gz → 0.17.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.
Files changed (24) hide show
  1. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/PKG-INFO +2 -2
  2. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/README.md +1 -1
  3. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/__init__.py +4 -2
  4. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/protocol-v1.schema.json +2 -19
  5. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/registry.json +2 -11
  6. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/skills/agent-first-data/references/registry.json +2 -11
  7. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/skills/agent-first-data/references/rules.md +13 -12
  8. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/cli.py +4 -10
  9. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/format.py +77 -192
  10. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data.egg-info/PKG-INFO +2 -2
  11. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/pyproject.toml +1 -1
  12. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/tests/test_cli.py +10 -3
  13. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/tests/test_format.py +17 -16
  14. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/skills/agent-first-data/SKILL.md +0 -0
  15. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/assets/skills/agent-first-data/references/protocol-v1.schema.json +0 -0
  16. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/skill.py +0 -0
  17. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data/stream_redirect.py +0 -0
  18. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data.egg-info/SOURCES.txt +0 -0
  19. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data.egg-info/dependency_links.txt +0 -0
  20. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/agent_first_data.egg-info/top_level.txt +0 -0
  21. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/setup.cfg +0 -0
  22. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/tests/test_no_stderr_policy.py +0 -0
  23. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/tests/test_skill.py +0 -0
  24. {agent_first_data-0.16.0 → agent_first_data-0.17.0}/tests/test_stream_redirect.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: A naming convention that lets AI agents understand your data without being told what it means.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -28,7 +28,7 @@ print(output_json(value))
28
28
  print(output_plain(value))
29
29
  ```
30
30
 
31
- Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `parse_size`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
31
+ Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
32
32
 
33
33
  Logging is available through `init_logging_json`, `init_logging_plain`, `init_logging_yaml`, `span`, and `get_logger`.
34
34
 
@@ -19,7 +19,7 @@ print(output_json(value))
19
19
  print(output_plain(value))
20
20
  ```
21
21
 
22
- Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `parse_size`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
22
+ Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
23
23
 
24
24
  Logging is available through `init_logging_json`, `init_logging_plain`, `init_logging_yaml`, `span`, and `get_logger`.
25
25
 
@@ -28,8 +28,9 @@ from agent_first_data.format import (
28
28
  output_plain,
29
29
  redacted_value,
30
30
  redact_url_secrets,
31
- parse_size,
32
31
  normalize_utc_offset,
32
+ is_valid_bcp47,
33
+ is_valid_rfc3339,
33
34
  is_valid_rfc3339_date,
34
35
  is_valid_rfc3339_time,
35
36
  )
@@ -75,8 +76,9 @@ __all__ = [
75
76
  "output_plain",
76
77
  "redacted_value",
77
78
  "redact_url_secrets",
78
- "parse_size",
79
79
  "normalize_utc_offset",
80
+ "is_valid_bcp47",
81
+ "is_valid_rfc3339",
80
82
  "is_valid_rfc3339_date",
81
83
  "is_valid_rfc3339_time",
82
84
  "OutputFormat",
@@ -47,13 +47,7 @@
47
47
  "type": "object",
48
48
  "properties": {
49
49
  "kind": {"const": "progress"},
50
- "progress": {
51
- "type": "object",
52
- "properties": {
53
- "message": {"type": "string", "minLength": 1}
54
- },
55
- "additionalProperties": true
56
- },
50
+ "progress": true,
57
51
  "trace": {"$ref": "#/$defs/trace"}
58
52
  },
59
53
  "required": ["kind", "progress"],
@@ -63,18 +57,7 @@
63
57
  "type": "object",
64
58
  "properties": {
65
59
  "kind": {"const": "log"},
66
- "log": {
67
- "type": "object",
68
- "properties": {
69
- "message": {"type": "string", "minLength": 1},
70
- "level": {"enum": ["debug", "info", "warn", "error"]},
71
- "code": false
72
- },
73
- "not": {
74
- "required": ["code"]
75
- },
76
- "additionalProperties": true
77
- },
60
+ "log": true,
78
61
  "trace": {"$ref": "#/$defs/trace"}
79
62
  },
80
63
  "required": ["kind", "log"],
@@ -132,7 +132,7 @@
132
132
  "unit": "bcp47_language_tag",
133
133
  "formatting": {"strip_key": false, "plain_yaml": "pass_through"},
134
134
  "redaction": {"mode": "none"},
135
- "constraints": {"string": true}
135
+ "constraints": {"bcp47": true}
136
136
  },
137
137
  {
138
138
  "suffix": "_utc_offset",
@@ -152,18 +152,9 @@
152
152
  "redaction": {"mode": "none"},
153
153
  "constraints": {"integer": true, "minimum": 0}
154
154
  },
155
- {
156
- "suffix": "_size",
157
- "category": "size",
158
- "json_types": ["string"],
159
- "unit": "byte_size_input",
160
- "formatting": {"strip_key": false, "plain_yaml": "pass_through"},
161
- "redaction": {"mode": "none"},
162
- "constraints": {"parse_size_units": ["B", "kB", "MB", "GB", "TB", "KiB", "MiB", "GiB", "TiB"]}
163
- },
164
155
  {
165
156
  "suffix": "_percent",
166
- "category": "ratio",
157
+ "category": "percentage",
167
158
  "json_types": ["number"],
168
159
  "unit": "percent",
169
160
  "formatting": {"strip_key": true, "plain_yaml": "append_percent"},
@@ -132,7 +132,7 @@
132
132
  "unit": "bcp47_language_tag",
133
133
  "formatting": {"strip_key": false, "plain_yaml": "pass_through"},
134
134
  "redaction": {"mode": "none"},
135
- "constraints": {"string": true}
135
+ "constraints": {"bcp47": true}
136
136
  },
137
137
  {
138
138
  "suffix": "_utc_offset",
@@ -152,18 +152,9 @@
152
152
  "redaction": {"mode": "none"},
153
153
  "constraints": {"integer": true, "minimum": 0}
154
154
  },
155
- {
156
- "suffix": "_size",
157
- "category": "size",
158
- "json_types": ["string"],
159
- "unit": "byte_size_input",
160
- "formatting": {"strip_key": false, "plain_yaml": "pass_through"},
161
- "redaction": {"mode": "none"},
162
- "constraints": {"parse_size_units": ["B", "kB", "MB", "GB", "TB", "KiB", "MiB", "GiB", "TiB"]}
163
- },
164
155
  {
165
156
  "suffix": "_percent",
166
- "category": "ratio",
157
+ "category": "percentage",
167
158
  "json_types": ["number"],
168
159
  "unit": "percent",
169
160
  "formatting": {"strip_key": true, "plain_yaml": "append_percent"},
@@ -37,6 +37,10 @@ The field name is the schema. Always encode units and semantics in the field nam
37
37
  | `_epoch_ns` | nanoseconds since Unix epoch as a decimal string | `created_epoch_ns: "1707868800000000000"` |
38
38
  | `_rfc3339` | RFC 3339 date-time string | `expires_rfc3339: "2026-02-14T10:30:00Z"` |
39
39
 
40
+ Integer vs string is not uniform across siblings: `_epoch_s` and `_epoch_ms` are JSON integers, but `_epoch_ns` is a decimal **string** because nanoseconds exceed the 2⁵³−1 safe-integer range. The same rule applies to large `_sats`/`_msats` (integer when safe, decimal string when not). Writing `_epoch_ns` as a bare JSON number silently loses precision — `afdata lint` rejects it (`suffix_type_mismatch`).
41
+
42
+ `_rfc3339` needs a **mandatory** offset: `YYYY-MM-DDThh:mm:ss[.fff]` followed by `Z` or `±HH:MM`. A bare `2026-02-14T10:30:00` (no offset), a space instead of `T`, or a trailing IANA name (`...Asia/Shanghai`) is rejected by `afdata lint`. `afdata lint` also type-checks the numeric suffixes (durations `_s`/`_ms`/…, currency `_cents`/`_micro`/`_jpy`) and rejects a `_url` with internal whitespace or bare `user:pass@host` credentials.
43
+
40
44
  ### Strict string formats
41
45
 
42
46
  | Suffix | Format | Example |
@@ -46,7 +50,7 @@ The field name is the schema. Always encode units and semantics in the field nam
46
50
  | `_rfc3339_date` | RFC 3339 full-date string | `invoice_due_rfc3339_date: "2026-06-13"` |
47
51
  | `_rfc3339_time` | RFC 3339 partial-time string | `market_open_rfc3339_time: "09:30:00"` |
48
52
 
49
- `*_bcp47` identifies a BCP-47 language tag string. AFDATA does not implement the full BCP-47 registry; tools may validate tags when needed.
53
+ `*_bcp47` identifies a BCP-47 language tag string. AFDATA validates structure (hyphen-separated subtags, 2–3 letter primary language), so the POSIX form `zh_CN` and an over-long primary like `chinese` are rejected; use `zh-CN`. It does not check the IANA registry, so a well-formed-but-unregistered tag like `zz-ZZ` still passes. `is_valid_bcp47` and `afdata lint` apply this check.
50
54
 
51
55
  `*_utc_offset` identifies a fixed UTC offset. Canonical persisted and structured output values are `"UTC"` or `±HH:MM`, with `HH` in `00..23` and `MM` in `00..59`; zero offsets normalize to `"UTC"`. This is not an IANA timezone name, DST rule, or timezone database field.
52
56
 
@@ -63,11 +67,8 @@ Avoid magic string sentinels such as `"auto"` inside strict-format fields. If a
63
67
  | Suffix | Example |
64
68
  |:-------|:--------|
65
69
  | `_bytes` | `payload_bytes: 456789` (non-negative integer) |
66
- | `_size` | `buffer_size: "10MiB"` or `"10MB"` (config files only, human-readable) |
67
70
 
68
- `_size` parsing requires explicit units: `B`, decimal `kB/MB/GB/TB`, or binary `KiB/MiB/GiB/TiB`. Bare numbers and ambiguous `K/M/G/T` are invalid.
69
-
70
- `parse_size("10MiB")` → `10485760`; `parse_size("10MB")` → `10000000`. Returns null for invalid or negative input.
71
+ Byte sizes are always integer `_bytes`, in config and output alike. There is no unit-in-value size string: never write `buffer_size: "10MiB"`. Encode the unit in the key (`buffer_bytes: 10485760`), the same way durations use `timeout_s: 30`.
71
72
 
72
73
  ### Percentage
73
74
 
@@ -75,6 +76,8 @@ Avoid magic string sentinels such as `"auto"` inside strict-format fields. If a
75
76
  |:-------|:--------|
76
77
  | `_percent` | `cpu_percent: 85` |
77
78
 
79
+ Value is in units of percent: `1` = 1%, `0.2` = 0.2%, `85` = 85%. Decimals, negatives, and values >100 are all valid; no fixed range. `%` is exactly 0.01, so `_percent` is never a 0–1 fraction — if the underlying ratio is `0.999`, multiply by 100 and write `success_percent: 99.9`. Writing `0.85` for 85% is a producer-side conversion bug, not a convention ambiguity.
80
+
78
81
  ### Currency
79
82
 
80
83
  Bitcoin:
@@ -150,7 +153,6 @@ ORM struct fields preserve the suffix: `duration_ms: i64`, not `duration: i64`.
150
153
  | `latency: 142` | `latency_ms: 142` | seconds? milliseconds? |
151
154
  | `api_key: "sk-..."` | `api_key_secret: "sk-..."` | won't be auto-redacted |
152
155
  | `cpu: 85` | `cpu_percent: 85` | 85 what? |
153
- | `buffer: "10MiB"` | `buffer_size: "10MiB"` | only `_size` gets parsed |
154
156
 
155
157
  ---
156
158
 
@@ -174,7 +176,7 @@ Remove recognized formatting suffix from key. Longest match first, exact lowerca
174
176
  4. `_msats`, `_sats`, `_bytes`, `_percent`, `_secret`
175
177
  5. `_jpy`, `_ns`, `_us`, `_ms`, `_s`
176
178
 
177
- `_size`, `_bcp47`, `_utc_offset`, `_rfc3339_date`, and `_rfc3339_time` are NOT stripped (pass through). If two keys collide after stripping, both revert to original key AND raw value (no formatting). Redaction runs before collision handling, so fallback never restores a secret.
179
+ `_bcp47`, `_utc_offset`, `_rfc3339_date`, and `_rfc3339_time` are NOT stripped (pass through). If two keys collide after stripping, both revert to original key AND raw value (no formatting). Redaction runs before collision handling, so fallback never restores a secret.
178
180
 
179
181
  ### Value formatting (YAML and Plain)
180
182
 
@@ -184,7 +186,6 @@ Remove recognized formatting suffix from key. Longest match first, exact lowerca
184
186
  - `_epoch_ms`/`_epoch_s`/decimal-string `_epoch_ns` → RFC 3339 (negative = pre-1970)
185
187
  - `_rfc3339` → pass through
186
188
  - `_bytes` → human-readable (`456789` → `446.1KiB`); negative and fractional byte values fall through raw
187
- - `_size` → pass through
188
189
  - `_percent` → append `%`
189
190
  - `_msats` → `{n}msats`, `_sats` → `{n}sats`
190
191
  - `_usd_cents` → `$X.XX`, `_eur_cents` → `€X.XX`, `_jpy` → `¥X,XXX`, `_{code}_cents` → `X.XX CODE`, `_{code}_micro` → `X.XXXXXX CODE` where `code` is 3-4 ASCII letters
@@ -258,7 +259,7 @@ Startup payload fields are tool-defined; `config` is recommended, while `version
258
259
 
259
260
  All use `kind` plus the same-named payload and optional `trace`. Do not split protocol events across `stdout` and `stderr`.
260
261
 
261
- Base validators enforce mandatory envelope/error/lifecycle rules. Strict validators additionally require object-valued `trace` on every event, complete log fields (`message`, `level`; `code` field MUST NOT appear in log payload), `retryable` on every error, and a non-empty progress `message`. Use strict validation before claiming compliance with all recommendations.
262
+ Base validators enforce mandatory envelope/error/lifecycle rules. Strict validators additionally require object-valued `trace` on every event and `retryable` on every error. Log and progress payloads remain tool-defined in both profiles. Use strict validation before claiming compliance with all recommendations.
262
263
 
263
264
  ---
264
265
 
@@ -298,11 +299,11 @@ let span = info_span!("request", request_id = %uuid);
298
299
  let _guard = span.enter();
299
300
  ```
300
301
 
301
- For Go, Python, and TypeScript: emit span fields explicitly in each log event via `.field()` or equivalent event construction.
302
+ For Go, Python, and TypeScript: emit span fields explicitly in each tool-defined log payload.
302
303
 
303
304
  ### Output fields
304
305
 
305
- Every log line contains `message`, `level` (debug/info/warn/error), plus span fields and event fields. Log payloads MUST NOT contain a `code` field. Do not use the log level as `code`; `code:"error"` is reserved for terminal protocol errors. Projects that need timestamps add them as extension fields (`timestamp_epoch_ms`) via emitter defaults or per-call `.field()`.
306
+ Log payloads have no required or reserved fields. Traditional logging adapters commonly add `message` and `level` (debug/info/warn/error), plus span and event fields, but those names and meanings are adapter conventions rather than AFDATA protocol requirements. Projects that need timestamps add `timestamp_epoch_ms` explicitly.
306
307
 
307
308
  Log redaction is **by field name** (the same `_secret`/`_url` rule as all output), applied when the line is emitted. Name the secret field — `info!(api_key_secret = %key)` — rather than logging a whole object by its `Debug`/string rendering, which hides the inner field names from redaction. For structured/nested secret-bearing data, build a value, apply redaction through output formatters or redacted_value(), then emit via the standard formatting helpers — do not pass the struct to a `?`/`%`-rendered log field.
308
309
 
@@ -340,7 +341,7 @@ Use this checklist when the skill is evaluating a change, not just documenting c
340
341
  3. Require secret-bearing values to be named with `_secret` or configured exact secret names before serialization. Do not rely on arbitrary free-text scanning or `Debug`/string-rendered objects.
341
342
  4. Run `afdata lint` for JSON/JSONL samples, JSON Schema, MCP schemas, or serialized outputs when available; run `afdata validate` for finite protocol events/streams. If unavailable, report the skipped check.
342
343
  5. Before changing an external project's public API, wire format, database schema, or persisted field names, report the compatibility impact and request approval before editing.
343
- 6. Config size values use `_size` suffix (`buffer_size: "10MiB"`, not `buffer: "10MiB"`)
344
+ 6. Byte sizes use integer `_bytes` everywhere, config included (`buffer_bytes: 10485760`, never `buffer_size: "10MiB"`)
344
345
  7. Environment variables follow `UPPER_SNAKE_CASE` with the same suffixes
345
346
  8. One-time CLI output uses `json_log()`/`CliEmitter` + output helpers; long-running services use `afdata_tracing::try_init()` (Rust) to capture via tracing; other languages emit log events explicitly via builders
346
347
  9. Database columns use AFDATA suffixes on generic types (`duration_ms INTEGER`, not `duration INTEGER`); native types like `TIMESTAMPTZ` don't need suffixes
@@ -168,14 +168,8 @@ class CliEmitter:
168
168
  # Apply log fields provider if this is a log event
169
169
  if kind == "log" and self._log_fields_provider is not None:
170
170
  provider_fields = self._log_fields_provider()
171
- if provider_fields:
172
- # Check for reserved fields from provider
173
- log_payload = envelope.get("log", {})
174
- for reserved in ("message", "level", "code"):
175
- if reserved in provider_fields:
176
- raise RuntimeError(
177
- f"log fields provider cannot write reserved field '{reserved}'"
178
- )
171
+ log_payload = envelope.get("log")
172
+ if provider_fields and isinstance(log_payload, dict):
179
173
  # Merge provider fields, explicit fields take precedence
180
174
  merged_log = dict(provider_fields)
181
175
  merged_log.update(log_payload)
@@ -211,7 +205,7 @@ class CliEmitter:
211
205
  if not message or not isinstance(message, str):
212
206
  raise ValueError("message must be a non-empty string")
213
207
  # Use the builder to get default trace: {}
214
- event = json_progress(message).build()
208
+ event = json_progress({"message": message}).build()
215
209
  self.emit(event)
216
210
 
217
211
  def emit_log(self, level: LogLevel | str, message: str) -> None:
@@ -220,7 +214,7 @@ class CliEmitter:
220
214
  level = LogLevel(level)
221
215
  if not message or not isinstance(message, str):
222
216
  raise ValueError("message must be a non-empty string")
223
- event = json_log(level, message).build()
217
+ event = json_log({"level": level.value, "message": message}).build()
224
218
  self.emit(event)
225
219
 
226
220
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Protocol builders, value redactors (copy and in-place; cover _secret and
4
4
  _url fields), output formatters, URL-string redactors (redact_url_secrets),
5
- parse_size, normalize_utc_offset, is_valid_rfc3339_date,
5
+ normalize_utc_offset, is_valid_rfc3339_date,
6
6
  is_valid_rfc3339_time, RedactionPolicy, OutputStyle, and
7
7
  OutputOptions. Each formatter/redactor concept is a single function taking
8
8
  a keyword-only ``options`` parameter.
@@ -183,50 +183,11 @@ class ErrorBuilder:
183
183
  class ProgressBuilder:
184
184
  """Fluent builder for progress events."""
185
185
 
186
- def __init__(self, message: str) -> None:
187
- self._message = message
186
+ def __init__(self, payload: Any) -> None:
187
+ self._payload = payload
188
188
  self._trace: dict = {}
189
- self._fields: dict[str, Any] = {}
190
189
  self._errors: list[str] = []
191
190
 
192
- def field(self, name: str, value: Any) -> ProgressBuilder:
193
- """Add a single extension field."""
194
- if name == "message":
195
- self._errors.append(f"cannot override reserved field '{name}'")
196
- else:
197
- self._fields[name] = value
198
- return self
199
-
200
- def fields(self, mapping: Mapping[str, Any]) -> ProgressBuilder:
201
- """Add multiple extension fields from a mapping."""
202
- if not isinstance(mapping, Mapping):
203
- self._errors.append("fields must be a mapping")
204
- else:
205
- for k, v in mapping.items():
206
- if k == "message":
207
- self._errors.append(f"cannot override reserved field '{k}'")
208
- else:
209
- self._fields[k] = v
210
- return self
211
-
212
- def extend(self, value: Any) -> ProgressBuilder:
213
- """Add extension fields from a dataclass or mapping."""
214
- if hasattr(value, "__dataclass_fields__"):
215
- from dataclasses import asdict
216
- mapping = asdict(value)
217
- elif isinstance(value, Mapping):
218
- mapping = value
219
- else:
220
- self._errors.append("extend() requires a dataclass or mapping")
221
- return self
222
-
223
- for k, v in mapping.items():
224
- if k == "message":
225
- self._errors.append(f"cannot override reserved field '{k}'")
226
- else:
227
- self._fields[k] = v
228
- return self
229
-
230
191
  def trace(self, obj: Any) -> ProgressBuilder:
231
192
  """Set trace context."""
232
193
  if not isinstance(obj, dict):
@@ -239,11 +200,9 @@ class ProgressBuilder:
239
200
  """Build and return the Event, raising EventBuildError if there are errors."""
240
201
  if self._errors:
241
202
  raise EventBuildError("; ".join(self._errors))
242
- progress = {"message": self._message}
243
- progress.update(self._fields)
244
203
  envelope = {
245
204
  "kind": "progress",
246
- "progress": progress,
205
+ "progress": self._payload,
247
206
  "trace": self._trace,
248
207
  }
249
208
  return Event(envelope)
@@ -252,53 +211,11 @@ class ProgressBuilder:
252
211
  class LogBuilder:
253
212
  """Fluent builder for log events."""
254
213
 
255
- def __init__(self, level: LogLevel | str, message: str) -> None:
256
- if isinstance(level, str):
257
- level = LogLevel(level)
258
- self._level = level
259
- self._message = message
214
+ def __init__(self, payload: Any) -> None:
215
+ self._payload = payload
260
216
  self._trace: dict = {}
261
- self._fields: dict[str, Any] = {}
262
217
  self._errors: list[str] = []
263
218
 
264
- def field(self, name: str, value: Any) -> LogBuilder:
265
- """Add a single extension field."""
266
- if name in ("message", "level", "code"):
267
- self._errors.append(f"cannot override reserved field '{name}'")
268
- else:
269
- self._fields[name] = value
270
- return self
271
-
272
- def fields(self, mapping: Mapping[str, Any]) -> LogBuilder:
273
- """Add multiple extension fields from a mapping."""
274
- if not isinstance(mapping, Mapping):
275
- self._errors.append("fields must be a mapping")
276
- else:
277
- for k, v in mapping.items():
278
- if k in ("message", "level", "code"):
279
- self._errors.append(f"cannot override reserved field '{k}'")
280
- else:
281
- self._fields[k] = v
282
- return self
283
-
284
- def extend(self, value: Any) -> LogBuilder:
285
- """Add extension fields from a dataclass or mapping."""
286
- if hasattr(value, "__dataclass_fields__"):
287
- from dataclasses import asdict
288
- mapping = asdict(value)
289
- elif isinstance(value, Mapping):
290
- mapping = value
291
- else:
292
- self._errors.append("extend() requires a dataclass or mapping")
293
- return self
294
-
295
- for k, v in mapping.items():
296
- if k in ("message", "level", "code"):
297
- self._errors.append(f"cannot override reserved field '{k}'")
298
- else:
299
- self._fields[k] = v
300
- return self
301
-
302
219
  def trace(self, obj: Any) -> LogBuilder:
303
220
  """Set trace context."""
304
221
  if not isinstance(obj, dict):
@@ -311,11 +228,9 @@ class LogBuilder:
311
228
  """Build and return the Event, raising EventBuildError if there are errors."""
312
229
  if self._errors:
313
230
  raise EventBuildError("; ".join(self._errors))
314
- log = {"level": self._level.value, "message": self._message}
315
- log.update(self._fields)
316
231
  envelope = {
317
232
  "kind": "log",
318
- "log": log,
233
+ "log": self._payload,
319
234
  "trace": self._trace,
320
235
  }
321
236
  return Event(envelope)
@@ -331,23 +246,22 @@ def json_error(code: str, message: str) -> ErrorBuilder:
331
246
  return ErrorBuilder(code, message)
332
247
 
333
248
 
334
- def json_progress(message: str) -> ProgressBuilder:
249
+ def json_progress(payload: Any) -> ProgressBuilder:
335
250
  """Create a fluent progress builder."""
336
- return ProgressBuilder(message)
251
+ return ProgressBuilder(payload)
337
252
 
338
253
 
339
- def json_log(level: LogLevel | str, message: str) -> LogBuilder:
254
+ def json_log(payload: Any) -> LogBuilder:
340
255
  """Create a fluent log builder."""
341
- return LogBuilder(level, message)
256
+ return LogBuilder(payload)
342
257
 
343
258
 
344
259
  def validate_protocol_event(event: Any, *, strict: bool = True) -> None:
345
260
  """Validate one protocol v1 event envelope.
346
261
 
347
262
  With ``strict=True`` (the default), also enforces the recommended strict
348
- protocol profile (required trace, required error.retryable, log/progress
349
- message and level rules). Pass ``strict=False`` for the plain, lenient
350
- envelope-shape check only.
263
+ protocol profile (required trace and required error.retryable). Pass
264
+ ``strict=False`` for the plain, lenient envelope-shape check only.
351
265
  """
352
266
  if not isinstance(event, dict):
353
267
  raise ValueError("event must be a JSON object")
@@ -370,10 +284,6 @@ def validate_protocol_event(event: Any, *, strict: bool = True) -> None:
370
284
  raise ValueError("event.trace is required by the strict profile")
371
285
  if kind == "error":
372
286
  _validate_strict_error_payload(event["error"])
373
- elif kind == "log":
374
- _validate_strict_log_payload(event["log"])
375
- elif kind == "progress":
376
- _validate_strict_progress_payload(event["progress"])
377
287
 
378
288
 
379
289
  def _validate_error_payload(error: Any) -> None:
@@ -426,26 +336,6 @@ def _validate_strict_error_payload(error: Any) -> None:
426
336
  raise ValueError("event.error.retryable must be a boolean in the strict profile")
427
337
 
428
338
 
429
- def _validate_strict_log_payload(log: Any) -> None:
430
- if not isinstance(log, dict):
431
- raise ValueError("event.log must be a JSON object in the strict profile")
432
- if "code" in log:
433
- raise ValueError("event.log must not contain 'code' field in the strict profile")
434
- _require_non_empty_string(log, "message", "event.log")
435
- if "level" not in log:
436
- raise ValueError("event.log.level is required by the strict profile")
437
- if log.get("level") not in ("debug", "info", "warn", "error"):
438
- raise ValueError(
439
- "event.log.level must be one of debug, info, warn, error in the strict profile"
440
- )
441
-
442
-
443
- def _validate_strict_progress_payload(progress: Any) -> None:
444
- if not isinstance(progress, dict):
445
- raise ValueError("event.progress must be a JSON object in the strict profile")
446
- _require_non_empty_string(progress, "message", "event.progress")
447
-
448
-
449
339
  def _require_non_empty_string(payload: dict, field: str, path: str) -> None:
450
340
  value = payload.get(field)
451
341
  if not isinstance(value, str) or value == "":
@@ -487,22 +377,17 @@ class DecodedError:
487
377
 
488
378
  @dataclass(frozen=True)
489
379
  class DecodedProgress:
490
- """Decoded protocol v1 progress event. ``fields`` holds extension fields
491
- beyond message."""
380
+ """Decoded protocol v1 progress event."""
492
381
 
493
- message: str
494
- fields: dict[str, Any] = field(default_factory=dict)
382
+ progress: Any
495
383
  trace: dict | None = None
496
384
 
497
385
 
498
386
  @dataclass(frozen=True)
499
387
  class DecodedLog:
500
- """Decoded protocol v1 log event. ``fields`` holds extension fields
501
- beyond level/message."""
388
+ """Decoded protocol v1 log event."""
502
389
 
503
- level: LogLevel
504
- message: str
505
- fields: dict[str, Any] = field(default_factory=dict)
390
+ log: Any
506
391
  trace: dict | None = None
507
392
 
508
393
 
@@ -544,16 +429,10 @@ def decode_protocol_event(
544
429
  )
545
430
 
546
431
  if kind == "progress":
547
- progress = event["progress"]
548
- fields = {k: v for k, v in progress.items() if k != "message"}
549
- return DecodedProgress(message=progress["message"], fields=fields, trace=trace)
432
+ return DecodedProgress(progress=event["progress"], trace=trace)
550
433
 
551
434
  # kind == "log"
552
- log = event["log"]
553
- fields = {k: v for k, v in log.items() if k not in ("level", "message")}
554
- return DecodedLog(
555
- level=LogLevel(log["level"]), message=log["message"], fields=fields, trace=trace
556
- )
435
+ return DecodedLog(log=event["log"], trace=trace)
557
436
 
558
437
 
559
438
  # ═══════════════════════════════════════════
@@ -678,58 +557,6 @@ def _apply_redaction_policy_with_context(
678
557
  _redact_secrets(value, context)
679
558
 
680
559
 
681
- def parse_size(s: str) -> int | None:
682
- """Parse a human-readable size string into bytes.
683
-
684
- Accepts numbers followed by explicit units. Decimal units are
685
- B/kB/MB/GB/TB; binary units are KiB/MiB/GiB/TiB. Trims whitespace and
686
- rejects ambiguous K/M/G/T units.
687
- """
688
- _multipliers = {
689
- "KiB": 1024,
690
- "MiB": 1024**2,
691
- "GiB": 1024**3,
692
- "TiB": 1024**4,
693
- "kB": 1000,
694
- "MB": 1000**2,
695
- "GB": 1000**3,
696
- "TB": 1000**4,
697
- "B": 1,
698
- }
699
- _max_safe_integer = (1 << 53) - 1
700
- s = s.strip()
701
- if not s:
702
- return None
703
- matched = next(((unit, mult) for unit, mult in _multipliers.items() if s.endswith(unit)), None)
704
- if matched is None:
705
- return None
706
- unit, mult = matched
707
- num_str = s[: -len(unit)]
708
- if not num_str:
709
- return None
710
- if not re.fullmatch(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", num_str):
711
- return None
712
- try:
713
- n = int(num_str)
714
- if n < 0:
715
- return None
716
- if n > _max_safe_integer // mult:
717
- return None
718
- return n * mult
719
- except ValueError:
720
- pass
721
- try:
722
- f = float(num_str)
723
- if f < 0 or not math.isfinite(f):
724
- return None
725
- result = f * mult
726
- if not math.isfinite(result) or result > _max_safe_integer:
727
- return None
728
- return int(result)
729
- except (ValueError, OverflowError):
730
- return None
731
-
732
-
733
560
  def normalize_utc_offset(value: str) -> str | None:
734
561
  """Normalize a fixed UTC offset string to "UTC" or ±HH:MM.
735
562
 
@@ -784,6 +611,64 @@ def is_valid_rfc3339_time(value: str) -> bool:
784
611
  return value[8] == "." and len(value) > 9 and value[9:].isdigit()
785
612
 
786
613
 
614
+ def is_valid_rfc3339(value: str) -> bool:
615
+ """Return true when value is a complete RFC 3339 date-time.
616
+
617
+ Composed from is_valid_rfc3339_date and is_valid_rfc3339_time: a full-date, a
618
+ T/t separator, a partial-time (with optional fractional seconds), and a
619
+ mandatory time-offset (Z/z or ±HH:MM with HH in 00..23 and MM in 00..59).
620
+ The offset is required, so a bare 2026-02-14T10:30:00 is rejected; a space
621
+ separator is rejected; and a leap second (:60) is rejected, matching
622
+ is_valid_rfc3339_time. Non-ASCII input is rejected.
623
+ """
624
+ if not isinstance(value, str) or len(value) < 20 or not value.isascii():
625
+ return False
626
+ if not is_valid_rfc3339_date(value[0:10]):
627
+ return False
628
+ if value[10] not in ("T", "t"):
629
+ return False
630
+ rest = value[11:]
631
+ if rest[-1] in ("Z", "z"):
632
+ partial = rest[:-1]
633
+ else:
634
+ if len(rest) < 6 or not _is_rfc3339_numoffset(rest[-6:]):
635
+ return False
636
+ partial = rest[:-6]
637
+ return is_valid_rfc3339_time(partial)
638
+
639
+
640
+ def _is_rfc3339_numoffset(offset: str) -> bool:
641
+ if len(offset) != 6 or offset[0] not in ("+", "-") or offset[3] != ":":
642
+ return False
643
+ hours = _parse_ascii_int(offset[1:3])
644
+ minutes = _parse_ascii_int(offset[4:6])
645
+ if hours is None or minutes is None:
646
+ return False
647
+ return hours <= 23 and minutes <= 59
648
+
649
+
650
+ def is_valid_bcp47(value: str) -> bool:
651
+ """Return true when value is a structurally well-formed BCP 47 language tag.
652
+
653
+ A grammar-level check, not a registry lookup: hyphen-separated ASCII-alphanumeric
654
+ subtags (each 1-8 chars) whose primary subtag is a 2-3 letter language code or the
655
+ x/i privateuse/grandfathered lead. Rejects the POSIX underscore form (zh_CN), empty
656
+ or misplaced hyphens, non-ASCII, and out-of-range primaries such as chinese. Does
657
+ not verify that subtags are registered with IANA.
658
+ """
659
+ if not isinstance(value, str) or not value:
660
+ return False
661
+ for index, subtag in enumerate(value.split("-")):
662
+ if not (1 <= len(subtag) <= 8) or not subtag.isascii() or not subtag.isalnum():
663
+ return False
664
+ if index == 0:
665
+ is_language = 2 <= len(subtag) <= 3 and subtag.isalpha()
666
+ is_special = subtag in ("x", "i")
667
+ if not is_language and not is_special:
668
+ return False
669
+ return True
670
+
671
+
787
672
  def _parse_utc_offset_body(body: str) -> tuple[int, int] | None:
788
673
  if not body:
789
674
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: A naming convention that lets AI agents understand your data without being told what it means.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -28,7 +28,7 @@ print(output_json(value))
28
28
  print(output_plain(value))
29
29
  ```
30
30
 
31
- Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `parse_size`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
31
+ Useful names use Python casing: `output_json`, `output_yaml`, `output_plain` (each takes an optional keyword-only `options=`), `redacted_value`, `redact_secrets_in_place`, `redact_url_secrets`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `cli_parse_output`, `cli_output`, `build_cli_error`, `build_cli_version`, `cli_handle_version_or_continue`, `decode_protocol_event`. Skill admin and stream-redirect helpers live in `agent_first_data.skill` and `agent_first_data.stream_redirect` (import the submodule directly).
32
32
 
33
33
  Logging is available through `init_logging_json`, `init_logging_plain`, `init_logging_yaml`, `span`, and `get_logger`.
34
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-first-data"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "A naming convention that lets AI agents understand your data without being told what it means."
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -140,7 +140,7 @@ def test_cli_output_dispatches_raw_yaml_with_options():
140
140
  def test_cli_emitter_writes_events_and_tracks_terminal():
141
141
  writer = StringIO()
142
142
  emitter = CliEmitter(writer, OutputFormat.JSON)
143
- emitter.emit(json_log(LogLevel.INFO, "startup").build())
143
+ emitter.emit(json_log({"level": "info", "message": "startup"}).build())
144
144
  emitter.emit(json_result({"rows": 2}).build())
145
145
  lines = writer.getvalue().splitlines()
146
146
  assert len(lines) == 2
@@ -150,7 +150,7 @@ def test_cli_emitter_writes_events_and_tracks_terminal():
150
150
 
151
151
  def test_cli_emitter_framing_all_formats():
152
152
  events = [
153
- json_log(LogLevel.INFO, "startup").build(),
153
+ json_log({"level": "info", "message": "startup"}).build(),
154
154
  json_result({"rows": 2}).build(),
155
155
  ]
156
156
  for fmt in (OutputFormat.JSON, OutputFormat.PLAIN, OutputFormat.YAML):
@@ -238,13 +238,20 @@ def test_cli_emitter_convenience_methods():
238
238
  def test_cli_emitter_with_log_fields_provider():
239
239
  writer = StringIO()
240
240
  def log_fields():
241
- return {"source": "test"}
241
+ return {
242
+ "source": "test",
243
+ "code": "cache_miss",
244
+ "message": "provider default",
245
+ "level": "debug",
246
+ }
242
247
  emitter = CliEmitter(writer, OutputFormat.JSON, log_fields=log_fields)
243
248
  emitter.emit_log(LogLevel.INFO, "test message")
244
249
  lines = writer.getvalue().splitlines()
245
250
  parsed = json.loads(lines[0])
246
251
  assert parsed["log"]["source"] == "test"
252
+ assert parsed["log"]["code"] == "cache_miss"
247
253
  assert parsed["log"]["message"] == "test message"
254
+ assert parsed["log"]["level"] == "info"
248
255
 
249
256
 
250
257
  # ── version helpers ───────────────────────────────────────────────────────────
@@ -28,12 +28,13 @@ from agent_first_data import (
28
28
  normalize_utc_offset,
29
29
  is_valid_rfc3339_date,
30
30
  is_valid_rfc3339_time,
31
+ is_valid_rfc3339,
32
+ is_valid_bcp47,
31
33
  )
32
34
  from agent_first_data.format import (
33
35
  _format_bytes_human,
34
36
  _format_with_commas,
35
37
  _extract_currency_code,
36
- parse_size,
37
38
  )
38
39
 
39
40
  FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "spec", "fixtures")
@@ -107,8 +108,8 @@ def _build_protocol_case_event(typ, args):
107
108
 
108
109
  args vocabulary: "result" (payload), "code"+"message" (error),
109
110
  "hint" (-> .hint()), "retryable" (bool -> .retryable_if()),
110
- "fields" (object -> .fields()), "trace" (object -> .trace()),
111
- "message" alone (progress), "level"+"message" (log).
111
+ "fields" (object -> .fields()), "trace" (object -> .trace()), and complete
112
+ progress/log payloads.
112
113
  """
113
114
  kind = typ.split("_", 1)[0]
114
115
 
@@ -121,13 +122,13 @@ def _build_protocol_case_event(typ, args):
121
122
  if "retryable" in args:
122
123
  builder = builder.retryable_if(args["retryable"])
123
124
  elif kind == "progress":
124
- builder = json_progress(args["message"])
125
+ builder = json_progress({"message": args["message"], **args.get("fields", {})})
125
126
  elif kind == "log":
126
- builder = json_log(LogLevel(args["level"]), args["message"])
127
+ builder = json_log({"level": args["level"], "message": args["message"], **args.get("fields", {})})
127
128
  else:
128
129
  raise ValueError(f"unknown fixture type: {typ}")
129
130
 
130
- if "fields" in args:
131
+ if "fields" in args and kind not in ("progress", "log"):
131
132
  builder = builder.fields(args["fields"])
132
133
  if "trace" in args:
133
134
  builder = builder.trace(args["trace"])
@@ -205,9 +206,6 @@ def test_helper_fixtures():
205
206
  elif name == "extract_currency_code":
206
207
  got = _extract_currency_code(inp)
207
208
  assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
208
- elif name == "parse_size":
209
- got = parse_size(inp)
210
- assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
211
209
  elif name == "normalize_utc_offset":
212
210
  got = normalize_utc_offset(inp)
213
211
  assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
@@ -217,6 +215,12 @@ def test_helper_fixtures():
217
215
  elif name == "is_valid_rfc3339_time":
218
216
  got = is_valid_rfc3339_time(inp)
219
217
  assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
218
+ elif name == "is_valid_bcp47":
219
+ got = is_valid_bcp47(inp)
220
+ assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
221
+ elif name == "is_valid_rfc3339":
222
+ got = is_valid_rfc3339(inp)
223
+ assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
220
224
 
221
225
 
222
226
  def test_output_format_fixtures():
@@ -388,21 +392,18 @@ def test_decode_protocol_event_error():
388
392
 
389
393
 
390
394
  def test_decode_protocol_event_progress():
391
- event = json_progress("halfway").field("percent", 50).build()
395
+ event = json_progress({"message": "halfway", "percent": 50}).build()
392
396
  decoded = decode_protocol_event(output_json(event.to_dict()))
393
397
  assert isinstance(decoded, DecodedProgress)
394
- assert decoded.message == "halfway"
395
- assert decoded.fields == {"percent": 50}
398
+ assert decoded.progress == {"message": "halfway", "percent": 50}
396
399
  assert decoded.trace == {}
397
400
 
398
401
 
399
402
  def test_decode_protocol_event_log():
400
- event = json_log(LogLevel.WARN, "slow query").field("duration_ms", 900).build()
403
+ event = json_log({"level": "warn", "message": "slow query", "duration_ms": 900}).build()
401
404
  decoded = decode_protocol_event(output_json(event.to_dict()))
402
405
  assert isinstance(decoded, DecodedLog)
403
- assert decoded.level == LogLevel.WARN
404
- assert decoded.message == "slow query"
405
- assert decoded.fields == {"duration_ms": 900}
406
+ assert decoded.log == {"level": "warn", "message": "slow query", "duration_ms": 900}
406
407
  assert decoded.trace == {}
407
408
 
408
409