Skip to content

fix(sdk-typescript): honor AGENTFIELD_LOG_STDOUT, and document the per-SDK logging knobs - #1020

Merged
AbirAbbas merged 5 commits into
mainfrom
fix/ext-sdk-log-stdout-parity
Aug 31, 2026
Merged

fix(sdk-typescript): honor AGENTFIELD_LOG_STDOUT, and document the per-SDK logging knobs#1020
AbirAbbas merged 5 commits into
mainfrom
fix/ext-sdk-log-stdout-parity

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

docs/api/AGENT_NODE_LOGS.md has always described AGENTFIELD_LOG_STDOUT as an SDK-agnostic way to turn the structured stdout mirror off, and the Python (_stdout_mirror_enabled) and Go (executionLogStdoutEnabled) SDKs both honor it — the TypeScript ExecutionLogger never read it, so a TypeScript node had no way to opt out. This teaches the TypeScript logger the same variable and fixes the environment-variable reference, which filed AGENTFIELD_LOG_STDOUT under "Python SDK agents" and made two claims about AGENTFIELD_LOG_MAX_LINE_BYTES that are wrong.

The default does not change: unset, empty, or any unrecognised value keeps the mirror on, and nothing in this repo sets the variable, so no compose file, manifest, desktop bundled node, or af template goes quiet on upgrade.

Why

Refs #985. That issue is about the Python structured-log path more broadly (the stdout dump on the event loop, the logging.StreamHandler RLock, and a request for an async-friendly queue); this PR only closes the cross-SDK half of it — the opt-out that Python and Go already have, missing in TypeScript — plus the documentation that pointed people at a knob one SDK did not implement. The locking and event-loop concerns in #985 are untouched, so this is Refs, not Fixes.

Changes

  • fix(sdk-typescript)mirrorToStdout becomes tri-state (boolean | undefined). An explicit constructor option still wins in both directions; when absent the flag resolves from AGENTFIELD_LOG_STDOUT per emit, not in the constructor, because Agent builds one shared ExecutionLogger and a snapshot would freeze the flag for the process lifetime (Python and Go both re-read per record). The accepted falsy spellings move into a new internal src/utils/envFlags.ts that processLogs.ts now shares, so the 0/false/no/off list cannot drift between modules. The helper guards process with typeof process !== 'undefined', matching the guard ExecutionLogger already used one line later for process.stdout. Serialization moved inside the mirror branch, so with the mirror off the JSON envelope is never built — the transport is handed the object, not the string.
  • test(sdk-python)node_logs.max_line_bytes() had no direct test even though its parsing is what the docs half now describes. Added a table pinning the clamp (100/0/-5 → 256), the default (abc → 16384), the 512abc rejection that TypeScript's parseInt would prefix-parse, and the pass-through (512 → 512).
  • docs(logging)AGENTFIELD_LOG_STDOUT moves to "Structured logging (SDKs)" with an explicit reader list and a pointer left behind in the Python section. The AGENTFIELD_LOG_MAX_LINE_BYTES entry drops the misleading minimum: 256 and the false claim that "the Go and TypeScript SDKs treat invalid values as unset". Two consequences that were previously undocumented are now stated so they are not later filed as regressions. Each SDK README gains a short Logging section carrying that SDK's own numbers.

Validation contract

Behaviour, and the test that covers it (all in sdk/typescript/tests/execution_logger.test.ts unless noted):

Contract Covering test
Unset, empty, or any value other than 0/false/no/off keeps the mirror on — identical to today keeps stdout mirroring enabled for AGENTFIELD_LOG_STDOUT=%j (undefined, '', true, 1, yes, on, ture)
false/0/no/off, any case, surrounding whitespace ignored, writes no structured line disables stdout mirroring for AGENTFIELD_LOG_STDOUT=%j without disabling transport (false, FALSE, " False ", 0, no, off)
An explicit mirrorToStdout overrides the environment in both directions lets mirrorToStdout=$option override AGENTFIELD_LOG_STDOUT=$env
The env is read per emit, not snapshotted in the constructor re-reads AGENTFIELD_LOG_STDOUT between emits on the same logger
It works through Agent's own construction path, the only path real users hit honors AGENTFIELD_LOG_STDOUT through the Agent construction path
process access is guarded, so the class stays usable off Node does not assume process exists when resolving stdout mirroring (stubs process to undefined)
Control-plane dispatch is unaffected for records carrying an execution id the disable test asserts transport.emit is still called once
The disabled path pays no serialization cost does not serialize the record when the mirror is disabled (a toJSON spy that must not fire)
Existing tests passing mirrorToStdout: false explicitly keep passing unchanged full npm test, unchanged assertions
The 0/false/no/off list is shared, not duplicated src/utils/envFlags.ts is the single definition; processLogs.ts logsEnabled() now calls it and its existing tests still pass
A reader can predict the effective cap per SDK for =100 — Python 256, Go 16384, TypeScript 16384 sdk/python/tests/test_node_logs.py::test_max_line_bytes_pins_python_parsing pins the Python column; Go and TypeScript are read off their existing code
Records with no execution context are stdout-only, so disabling the mirror drops them; disabling it also empties structured records out of GET /agentfield/v1/logs stated in docs/ENVIRONMENT_VARIABLES.md and docs/api/AGENT_NODE_LOGS.md — pre-existing behaviour in all three SDKs, documented so it is not later filed as a regression
AGENTFIELD_LOG_STDOUT is filed cross-SDK with a pointer left in the Python section; the minimum: 256 preamble is reworded; the Python clamp is described as governing both Python log paths; docs/api/AGENT_NODE_LOGS.md is updated for the 256 floor and the reader list docs diff
No code behaviour changes from the docs half the docs commit touches only .md files

How it was tested

Rebased onto origin/main (b458f9c3, v0.1.138-rc.2) and re-ran the literal CI steps for every surface this touches:

  • cd sdk/typescript && npm ci --no-audit --no-fund — pass
  • cd sdk/typescript && npm run lint — pass
  • cd sdk/typescript && npm test — pass
  • cd sdk/python && ruff check . (ruff 0.15.22) — pass
  • cd sdk/python && ./scripts/run_pytest.sh -q — pass
  • cd sdk/go && go mod tidy && git diff --exit-code go.mod go.sum, go build ./..., gofmt -l, go test -count=1 ./... — pass (no .go files change here; the Go surface is in scope only because sdk/go/README.md is touched)
  • docs: relative-link scan over the changed Markdown — pass

Every added line in src/utils/envFlags.ts and in the changed ExecutionLogger branch is exercised by the tests listed above, so patch coverage on the touched lines is full for the code half; the rest of the diff is Markdown and tests.

No live control plane was started — the change is SDK-local and every path is reachable from unit tests. Each number in the docs commit was read back out of the code rather than carried over from the previous prose:

  • sdk/python/agentfield/node_logs.py max_line_bytes()max(256, int(raw, 10)), ValueError → 16384
  • sdk/go/agent/process_logs.go processLogsMaxLineBytes()Atoi error or n < 256 → 16384
  • sdk/typescript/src/agent/processLogs.ts maxLineBytes()parseInt(raw, 10), kept only when finite and >= 256, else 16384

Notes / follow-ups

  • Not done here, worth a follow-up: three pre-existing stdout assertions (tests/execution_logger_methods.test.ts:97 and :157, tests/execution_logger.test.ts:111) construct loggers without an explicit mirrorToStdout and assert a line was written. They used to be env-independent because the default was a hardcoded true; they now resolve through the environment, so a developer with AGENTFIELD_LOG_STDOUT=false exported in their shell — plausible while working on exactly this feature — would see three confusing failures. CI cannot go red from this (the workflow does not set the variable). The fix is a beforeEach(() => vi.stubEnv('AGENTFIELD_LOG_STDOUT', undefined)) in both describe blocks; the afterEach(vi.unstubAllEnvs()) added here already restores it, but it only undoes vitest's own stubs, not an inherited value.
  • sdk/typescript/README.md links the environment reference with a relative path while sdk/python/README.md uses an absolute github.com URL. The Python one is deliberate — that file is the PyPI long_description, where relative links render dead. npm rewrites relative links using repository.url + directory, both of which sdk/typescript/package.json sets, so the relative form should resolve there too; worth making all three consistent at some point.
  • The AGENTFIELD_LOG_MAX_LINE_BYTES wording says "Python and Go reject non-integers, while TypeScript prefix-parses them". Strictly, TypeScript only prefix-parses values that start with digits — abc gives NaN and falls back to 16384 like the others. The parenthetical example (512abc512) carries the real distinction.
  • sdk/python/tests/test_node_logs.py puts the new parametrized test above the file's first section banner. Cosmetic only.
  • Deliberately out of scope: the RLock contention and event-loop blocking described in [Python SDK] Structured logs, event loop and Postgres instance #985, and any change to the Python emit path. Also unchanged is the divergence itself — Python clamping to 256 where Go and TypeScript reject upward to 16384 is now documented rather than unified, since aligning them would change behaviour for existing deployments.

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits August 31, 2026 12:01
docs/api/AGENT_NODE_LOGS.md has promised AGENTFIELD_LOG_STDOUT SDK-agnostically
since it was written, and the Python (`_stdout_mirror_enabled`) and Go
(`executionLogStdoutEnabled`) SDKs both honor it. The TypeScript
ExecutionLogger did not: `mirrorToStdout` defaulted to `true` and nothing ever
read the environment, so a TypeScript node had no way to turn the structured
stdout mirror off (#985).

`mirrorToStdout` becomes tri-state (`boolean | undefined`). An explicit option
still wins in both directions; when it is absent the flag is resolved from the
environment. The resolution happens per emit rather than in the constructor
because `Agent` builds one shared ExecutionLogger at construction time, so a
snapshot would freeze the flag for the whole process lifetime — Python and Go
both re-read it on every record.

The accepted falsy spellings (`0`/`false`/`no`/`off`, case-insensitive,
whitespace trimmed) move into a new internal `utils/envFlags` helper that
`processLogs.ts` now shares, so the list cannot drift between modules or
against the other SDKs. The helper guards `process` with
`typeof process !== 'undefined'`, matching the guard ExecutionLogger already
uses for `process.stdout`, so the class stays usable outside Node.

The default is unchanged: unset, empty, or any unrecognised value keeps the
mirror on, so a typo cannot silently drop log output, and nothing in the repo
sets this variable. Serialization now happens inside the mirror branch — with
the mirror off the JSON envelope is never built, which is the cost the flag
exists to avoid (the transport is handed the object, not the string).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`node_logs.max_line_bytes()` had no direct test, yet its parsing differs from
the Go and TypeScript SDKs in ways the environment-variable reference is about
to describe: Python clamps every integer below 256 up to 256 (including zero
and negatives) where Go and TypeScript reject those values and fall back to
16384, and Python's `int(raw, 10)` rejects `512abc` where TypeScript's
`parseInt` prefix-parses it to 512.

Table-driven so the documented matrix and the code cannot drift apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ine-cap claims

AGENTFIELD_LOG_STDOUT was documented under "Python SDK agents" even though the
Go SDK reads it too (and now the TypeScript SDK does). It moves to "Structured
logging (SDKs)" with an explicit reader list; a pointer stays in the Python
section so a reader scanning only their own section does not lose it. Two
consequences that were previously undocumented are stated so they are not
later filed as regressions: a record with no execution id is skipped by
control-plane dispatch in all three SDKs and is therefore dropped entirely when
the mirror is off, and because the node-log ring is fed by captured stdout,
disabling the mirror also empties structured records out of
GET /agentfield/v1/logs.

The AGENTFIELD_LOG_MAX_LINE_BYTES entry claimed "minimum: 256" and that "the Go
and TypeScript SDKs treat invalid values as unset". The first is misleading and
the second is false. Python clamps sub-256 integers up to 256; Go and
TypeScript reject them upward to the 16384 default, so `=100` yields a cap 64x
larger than requested. Python and Go reject non-integers outright while
TypeScript's parseInt prefix-parses (`512abc` -> 512). The Python clamp also
governs both Python log paths — the stdout/stderr tee behind
/agentfield/v1/logs and the structured-mirror elision budget — not just the
mirror. Every number here is pinned by the new test_node_logs.py table.

Each SDK README gains a short Logging section carrying that SDK's own numbers.
The Python one uses absolute github.com URLs because that file is the PyPI
long_description, where relative links render dead.

No code behaviour changes in this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 31, 2026 16:05
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.29 µs -17%
Go 223 B -20% 0.61 µs -39%
TS 494 B +41% 1.52 µs -24%

Regression detected:

  • TypeScript memory: 350 B → 494 B (+41%)

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.60% 87.40% ↑ +0.20 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.31% 93.73% ↑ +0.58 pp 🟢
sdk-typescript 91.72% 90.42% ↑ +1.30 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.81% 85.75% ↑ +0.06 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 9 100.00%
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@santoshkumarradha

Copy link
Copy Markdown
Member

okay this was needed ! thanks mang

@AbirAbbas
AbirAbbas merged commit 4b2b8bd into main Aug 31, 2026
33 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-sdk-log-stdout-parity branch August 31, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants