Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,10 @@ AGENTFIELD_CONNECTOR_CAP_DID_MANAGEMENT=false
### Structured logging (SDKs)

- `AGENTFIELD_LOGS_ENABLED` (default: `true`): Enables Python, Go, and TypeScript agent-node stdout/stderr capture and the `/agentfield/v1/logs` endpoint. This controls capture, not control-plane execution-log dispatch.
- `AGENTFIELD_LOG_STDOUT` (read by Python, Go, and TypeScript; default: on): Controls whether structured execution records are mirrored to stdout as JSON. Set to `0`, `false`, `no`, or `off` (case-insensitive, surrounding whitespace ignored) to suppress the mirror; control-plane dispatch continues unchanged for records carrying an execution ID. Any other value — including `1`, `true`, `yes`, an unset variable and a set-but-empty one — keeps the mirror on, so a typo cannot silently drop log output. All three SDKs skip control-plane dispatch for a record with no execution id, so such records are stdout-only and disabling the mirror drops them entirely. Because the node-log ring behind `GET /agentfield/v1/logs` is fed by the process's captured stdout, disabling the mirror also removes structured records from that ring.
- `AGENTFIELD_LOG_TRUNCATE` (Python default: `200` characters): Truncates human-readable plain log messages and visible plain-log payloads. It does not truncate structured records.
- `AGENTFIELD_LOG_PAYLOADS` (Python default: `false`): Shows payloads in human-readable plain logs when `true`. Structured execution attributes are unaffected.
- `AGENTFIELD_LOG_MAX_LINE_BYTES` (default: `16384`, minimum: `256`): Maximum emitted process-log line size in bytes. The Go and TypeScript SDKs treat invalid values or values below 256 as unset and use the 16384-byte default. The Python structured stdout mirror elides attributes, then the message or entire record as needed, so every emitted line—including the complete JSON envelope—is valid JSON and fits this cap.
- `AGENTFIELD_LOG_MAX_LINE_BYTES` (default: `16384`): Maximum process-log line size in bytes. Python clamps every integer below 256 (including zero and negatives) to 256; Go and TypeScript instead reject values below 256 and use the 16384-byte default. Python and Go reject non-integers, while TypeScript prefix-parses them (`512abc` becomes `512`). Thus a value of `100` yields an effective cap of 256 in Python and 16384 in Go and TypeScript: in those two SDKs there is no minimum, only a rejection *upward* to the default, so asking for a smaller cap silently gives you a 64x larger one. In Python this cap applies both to the stdout/stderr tee feeding `/agentfield/v1/logs` and to structured-mirror elision; the mirror elides attributes, then the message or entire record as needed so the complete JSON envelope remains valid JSON within the cap.
- `AGENTFIELD_LOG_BUFFER_BYTES` (default: `4194304`): Approximate total byte capacity of the in-memory process-log capture ring; oldest entries are discarded when full.

Agent nodes run as separate processes/pods and register with the control plane. The most important Kubernetes-specific concept is:
Expand Down Expand Up @@ -233,7 +234,7 @@ In Kubernetes, set the agent pod's `terminationGracePeriodSeconds` at least 10 s
- `AGENTFIELD_URL` (recommended): Control plane base URL.
- `AGENT_NODE_ID` (optional): Node id.
- `AGENT_CALLBACK_URL` (recommended in Docker/Kubernetes): URL the control plane will call back to (examples: `http://my-agent:8001`, or for host-run agents with Dockerized control plane: `http://host.docker.internal:8001`).
- `AGENTFIELD_LOG_STDOUT` (optional, default on): Controls whether the Python and Go SDKs mirror structured execution records to stdout as JSON. Set to `0`, `false`, `no` or `off` (case-insensitive, surrounding whitespace ignored) to suppress the mirror; forwarding to the control plane continues unchanged. Any other value — including `1`, `true`, `yes`, an unset variable and a set-but-empty one — keeps the mirror on, so a typo cannot silently drop log output.
- `AGENTFIELD_LOG_STDOUT`: see "Structured logging (SDKs)" above; it applies to the Python, Go, and TypeScript SDKs alike.
- `AGENTFIELD_SHUTDOWN_TIMEOUT` (optional, default `30s`): Graceful-shutdown budget for both direct HTTP requests and control-plane-dispatched reasoners. Accepts bare seconds (`30`), seconds (`30s`), or minutes (`5m`). `app.serve(timeout_graceful_shutdown=N)` remains supported and sets both budgets unless this environment variable is explicitly set; when both are set, the `serve()` argument still controls uvicorn's direct-HTTP drain while this variable controls dispatched reasoners.
- `AGENTFIELD_DISABLE_IP_DETECTION` (optional, default off): Set to `1`, `true` or `yes` (case-insensitive, surrounding whitespace ignored) to stop the Python SDK from probing the cloud metadata services (`169.254.169.254` for AWS/Azure, `metadata.google.internal` for GCP) and `https://api.ipify.org` for the node's public address. On Kubernetes those requests are typically denied by a `NetworkPolicy` and show up as deny-log noise. In detail:
- **What it disables.** The probe is one step of callback-URL discovery (`_detect_container_ip()`, whose only caller is `_build_callback_candidates()`), and it only runs when the SDK believes it is inside a container — `/.dockerenv` exists, `/proc/1/cgroup` mentions docker/containerd/kubepods, any `KUBERNETES_*` variable is set, or `CONTAINER` / `DOCKER_CONTAINER` / `RAILWAY_ENVIRONMENT` is set. This variable gates that single call site, so with it on the SDK makes no metadata or `api.ipify.org` request on any code path.
Expand Down
13 changes: 9 additions & 4 deletions docs/api/AGENT_NODE_LOGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ Agent nodes MAY expose process stdout/stderr for the control plane UI to proxy.

Capture is enabled by default and bounded by `AGENTFIELD_LOG_BUFFER_BYTES`
(default 4194304 bytes) and `AGENTFIELD_LOG_MAX_LINE_BYTES` (default 16384
bytes). Set `AGENTFIELD_LOGS_ENABLED=false` to disable capture and this API.
Structured SDK records are mirrored to stdout by default; set
`AGENTFIELD_LOG_STDOUT=false` to disable that mirror while retaining delivery
to the control plane.
bytes). Python clamps integer line caps below 256 to 256; Go and TypeScript
reject them and use the default. Set `AGENTFIELD_LOGS_ENABLED=false` to disable
capture and this API. The Python, Go, and TypeScript SDKs mirror structured
records to stdout by default; set `AGENTFIELD_LOG_STDOUT=false` to disable that
mirror while retaining control-plane delivery for records with an execution ID.
All three SDKs skip that delivery for a record with no execution id, so such
records are stdout-only and are dropped when mirroring is disabled. Since the
node-log ring is fed by captured stdout, disabling the mirror also removes
structured records from `GET /agentfield/v1/logs`.

## Agent endpoint

Expand Down
8 changes: 8 additions & 0 deletions sdk/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ result, err := approvalClient.WaitForApproval(waitCtx, nodeID, executionID,

**Methods:** `RequestApproval()`, `GetApprovalStatus()`, `WaitForApproval()`

## Logging

- `AGENTFIELD_LOG_STDOUT` controls the on-by-default structured JSON mirror; `0`, `false`, `no`, or `off` disables it without disabling control-plane dispatch.
- `AGENTFIELD_LOGS_ENABLED` controls stdout/stderr capture and the node logs endpoint, not control-plane dispatch.
- `AGENTFIELD_LOG_MAX_LINE_BYTES` defaults to 16384 bytes. Values below 256 and non-integers use that default; valid integers of 256 or greater are accepted.

See the [environment-variable reference](../../docs/ENVIRONMENT_VARIABLES.md) for details.

## Testing

```bash
Expand Down
9 changes: 9 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ See `examples/python_agent_nodes/waiting_state/` for a complete working example.

See `docs/DEVELOPMENT.md` for instructions on wiring agents to the control plane.

## Logging

- `AGENTFIELD_LOG_STDOUT` controls the on-by-default structured JSON mirror. Set it to `0`, `false`, `no`, or `off` to disable the mirror; execution-scoped records still dispatch to the control plane.
- `AGENTFIELD_LOG_MAX_LINE_BYTES` defaults to 16384 bytes and clamps any integer below 256 to 256. It limits both captured stdout/stderr lines and structured-mirror records; non-integers use the default.
- `AGENTFIELD_LOGS_ENABLED` controls stdout/stderr capture and the node logs endpoint only, not control-plane execution-log dispatch.
- `AGENTFIELD_LOG_LEVEL` controls human-readable Python SDK logging and defaults to `WARNING`.

See the [environment-variable reference](https://github.com/Agent-Field/agentfield/blob/main/docs/ENVIRONMENT_VARIABLES.md) and [agent-node logs API](https://github.com/Agent-Field/agentfield/blob/main/docs/api/AGENT_NODE_LOGS.md) for details.

## Testing

```bash
Expand Down
23 changes: 23 additions & 0 deletions sdk/python/tests/test_node_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,33 @@
get_ring,
install_stdio_tee,
iter_tail_ndjson,
max_line_bytes,
verify_internal_bearer,
)


@pytest.mark.unit
@pytest.mark.parametrize(
("raw", "expected"),
[
(None, 16384),
("100", 256),
("0", 256),
("-5", 256),
("abc", 16384),
("512abc", 16384),
("512", 512),
],
)
def test_max_line_bytes_pins_python_parsing(monkeypatch, raw, expected):
if raw is None:
monkeypatch.delenv("AGENTFIELD_LOG_MAX_LINE_BYTES", raising=False)
else:
monkeypatch.setenv("AGENTFIELD_LOG_MAX_LINE_BYTES", raw)

assert max_line_bytes() == expected


# ---------------------------------------------------------------------------
# LogEntry NDJSON serialization
# ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ agent.reasoner('process', async (ctx) => {

**Use `note()` for AgentField UI tracking, `console.log()` for local debugging.**

## Logging

- `AGENTFIELD_LOG_STDOUT` controls the on-by-default structured JSON mirror; `0`, `false`, `no`, or `off` disables it without disabling execution-scoped control-plane dispatch.
- `AGENTFIELD_LOGS_ENABLED` controls stdout/stderr capture and the node logs endpoint, not control-plane dispatch.
- `AGENTFIELD_LOG_MAX_LINE_BYTES` defaults to 16384 bytes. Values below 256 use that default; parsing accepts an integer prefix, so `512abc` yields 512.

See the [environment-variable reference](../../docs/ENVIRONMENT_VARIABLES.md) for details.

## Human-in-the-Loop Approvals

Use the `ApprovalClient` to pause agent execution for human review:
Expand Down
5 changes: 3 additions & 2 deletions sdk/typescript/src/agent/processLogs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { envFlagEnabled } from '../utils/envFlags.js';

type LogEntry = {
v: number;
seq: number;
Expand All @@ -10,8 +12,7 @@ type LogEntry = {
};

function logsEnabled(): boolean {
const v = (process.env.AGENTFIELD_LOGS_ENABLED ?? 'true').trim().toLowerCase();
return !['0', 'false', 'no', 'off'].includes(v);
return envFlagEnabled('AGENTFIELD_LOGS_ENABLED');
}

function maxBufferBytes(): number {
Expand Down
18 changes: 13 additions & 5 deletions sdk/typescript/src/observability/ExecutionLogger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { WriteStream } from 'node:tty';
import { envFlagEnabled } from '../utils/envFlags.js';

export type ExecutionLogLevel = 'debug' | 'info' | 'warn' | 'error';

Expand Down Expand Up @@ -146,14 +147,14 @@ export function serializeExecutionLogEntry(entry: ExecutionLogEntry): string {
export class ExecutionLogger {
private readonly contextProvider?: () => ExecutionLogContext | undefined;
private readonly transport?: ExecutionLogTransport;
private readonly mirrorToStdout: boolean;
private readonly mirrorToStdout: boolean | undefined;
private readonly stdout?: Pick<WriteStream, 'write'>;
private readonly defaultSource: string;

constructor(options: ExecutionLoggerOptions = {}) {
this.contextProvider = options.contextProvider;
this.transport = options.transport;
this.mirrorToStdout = options.mirrorToStdout ?? true;
this.mirrorToStdout = options.mirrorToStdout;
this.stdout = options.stdout ?? (typeof process !== 'undefined' ? process.stdout : undefined);
this.defaultSource = options.source ?? 'sdk.logger';
}
Expand Down Expand Up @@ -227,10 +228,13 @@ export class ExecutionLogger {

private emit(entry: ExecutionLogEntry): void {
const wire = normalizeExecutionLogEntry(entry);
const line = safeJsonStringify(wire) + '\n';

if (this.mirrorToStdout && this.stdout?.write) {
this.stdout.write(line);
// Serialize lazily: with the mirror disabled the JSON envelope is never
// needed, and paying for it anyway is exactly the cost the flag exists to
// avoid (the Python SDK skips serialization on this path for the same
// reason). The transport receives the object, not this string.
if (this.shouldMirrorToStdout() && this.stdout?.write) {
this.stdout.write(safeJsonStringify(wire) + '\n');
}

if (this.transport && wire.execution_id) {
Expand All @@ -244,6 +248,10 @@ export class ExecutionLogger {
}
}
}

private shouldMirrorToStdout(): boolean {
return this.mirrorToStdout ?? envFlagEnabled('AGENTFIELD_LOG_STDOUT');
}
}

export function createExecutionLogger(options: ExecutionLoggerOptions = {}): ExecutionLogger {
Expand Down
13 changes: 13 additions & 0 deletions sdk/typescript/src/utils/envFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Values that switch an on-by-default AgentField flag off. */
export const DISABLED_FLAG_VALUES: readonly string[] = ['0', 'false', 'no', 'off'];

/** Reads an environment variable without assuming a Node runtime. */
export function readEnvValue(name: string): string | undefined {
if (typeof process === 'undefined' || !process.env) return undefined;
return process.env[name];
}

/** Returns false only for an explicitly disabled on-by-default flag. */
export function envFlagEnabled(name: string): boolean {
return !DISABLED_FLAG_VALUES.includes((readEnvValue(name) ?? 'true').trim().toLowerCase());
}
118 changes: 117 additions & 1 deletion sdk/typescript/tests/execution_logger.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Agent } from '../src/agent/Agent.js';
import { AgentFieldClient } from '../src/client/AgentFieldClient.js';
import { ExecutionContext, type ExecutionMetadata } from '../src/context/ExecutionContext.js';
Expand All @@ -8,8 +8,108 @@ import {
} from '../src/observability/ExecutionLogger.js';

describe('ExecutionLogger', () => {
beforeEach(() => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

it.each(['false', 'FALSE', ' False ', '0', 'no', 'off'])(
'disables stdout mirroring for AGENTFIELD_LOG_STDOUT=%j without disabling transport',
(value) => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', value);
const write = vi.fn();
const emit = vi.fn();
const logger = createExecutionLogger({
contextProvider: () => ({ executionId: 'exec-env-off' }),
stdout: { write },
transport: { emit }
});

logger.info('still dispatched');

expect(write).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledOnce();
}
);

it.each([undefined, '', 'true', '1', 'yes', 'on', 'ture'])(
'keeps stdout mirroring enabled for AGENTFIELD_LOG_STDOUT=%j',
(value) => {
if (value === undefined) {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', undefined);
} else {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', value);
}
const write = vi.fn();
const logger = createExecutionLogger({ stdout: { write } });

logger.info('mirrored');

expect(write).toHaveBeenCalledOnce();
}
);

it.each([
{ option: true, env: 'false', expectedWrites: 1 },
{ option: false, env: 'true', expectedWrites: 0 }
])('lets mirrorToStdout=$option override AGENTFIELD_LOG_STDOUT=$env', ({ option, env, expectedWrites }) => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', env);
const write = vi.fn();
const logger = createExecutionLogger({ mirrorToStdout: option, stdout: { write } });

logger.info('explicit override');

expect(write).toHaveBeenCalledTimes(expectedWrites);
});

it('does not serialize the record when the mirror is disabled', () => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', 'false');
let serialized = false;
const emit = vi.fn();
const logger = createExecutionLogger({
contextProvider: () => ({ executionId: 'exec-no-serialize' }),
stdout: { write: vi.fn() },
transport: { emit }
});

logger.info('disabled', {
payload: {
toJSON: () => {
serialized = true;
return 'x';
}
}
});

expect(serialized).toBe(false);
expect(emit).toHaveBeenCalledOnce();
});

it('re-reads AGENTFIELD_LOG_STDOUT between emits on the same logger', () => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', 'true');
const write = vi.fn();
const logger = createExecutionLogger({ stdout: { write } });

logger.info('first');
vi.stubEnv('AGENTFIELD_LOG_STDOUT', 'off');
logger.info('second');

expect(write).toHaveBeenCalledOnce();
});

it('does not assume process exists when resolving stdout mirroring', () => {
const write = vi.fn();
const logger = createExecutionLogger({ stdout: { write } });
vi.stubGlobal('process', undefined);

logger.info('browser-safe');

expect(write).toHaveBeenCalledOnce();
});

it('serializes execution context to the backend envelope and mirrors to stdout', () => {
Expand Down Expand Up @@ -138,8 +238,24 @@ describe('ExecutionLogger', () => {
});

describe('Agent execution logging', () => {
beforeEach(() => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', undefined);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

it('honors AGENTFIELD_LOG_STDOUT through the Agent construction path', () => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', 'false');
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const agent = new Agent({ nodeId: 'local', devMode: true });

agent.getExecutionLogger().info('not mirrored');

expect(write).not.toHaveBeenCalled();
});

it('emits structured runtime logs for a local reasoner execution', async () => {
Expand Down
9 changes: 8 additions & 1 deletion sdk/typescript/tests/execution_logger_methods.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ExecutionLogger,
createExecutionLogger,
Expand All @@ -9,6 +9,13 @@ import {
} from '../src/observability/ExecutionLogger.js';

describe('ExecutionLogger exported API', () => {
beforeEach(() => {
vi.stubEnv('AGENTFIELD_LOG_STDOUT', undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
});
it('normalizes and serializes entries including bigint and circular attributes', () => {
const circular: { self?: unknown } = {};
circular.self = circular;
Expand Down
Loading