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: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ jobs:
env:
LIBS: chat,langgraph,ag-ui,render,a2ui,telemetry,middleware
FOUNDATIONS: core,content,angular,react
THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT: /tmp/threadplane-agui-client-tools-native-fixture-env
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
Expand Down Expand Up @@ -188,6 +189,10 @@ jobs:
run: node scripts/react-parity/review-native-ag-ui.mjs --verify
- name: Verify real LangGraph candidate package
run: node scripts/react-parity/verify-langgraph-candidate.mjs
- name: Prepare canonical AG-UI client-tools fixture
run: UV_PYTHON=3.12 UV_PROJECT_ENVIRONMENT="$THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT" uv sync --frozen --no-install-project --project cockpit/ag-ui/client-tools/python
- name: Verify canonical AG-UI client-tools fixtures
run: node --test scripts/react-parity/ag-ui-candidate-package.spec.mjs fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs
- name: Verify real AG-UI candidate package
run: node scripts/react-parity/verify-ag-ui-candidate.mjs
- name: Verify installed owned Markdown
Expand Down
49 changes: 41 additions & 8 deletions fixtures/react-parity/ag-ui-candidate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ selector. A fixture entry directly forwards their real exports and types. The
temporary `@threadplane/ag-ui` package keeps version 0.2.0 and does not change the
published Angular package root or introduce a permanent migration API.

With the repository's existing dependencies available, build the prerequisites
and install the exact local service lock:
With the repository's existing npm dependencies, Python 3.12 and `uv` available,
build the prerequisites and install both exact local service locks:

```sh
NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,angular,react --skip-nx-cache --outputStyle=stream
NX_DAEMON=false NX_TUI=false npx nx run-many -t build -p core,content,angular,react --skip-nx-cache --outputStyle=stream
npm ci --prefix deployments/ag-ui-mastra --no-audit --no-fund
export THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT=/tmp/threadplane-agui-client-tools-native-fixture-env
UV_PYTHON=3.12 UV_PROJECT_ENVIRONMENT="$THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT" uv sync --frozen --no-install-project --project cockpit/ag-ui/client-tools/python
node --test scripts/react-parity/ag-ui-candidate-package.spec.mjs fixtures/react-parity/native-ag-ui-client-tools/server.spec.mjs
node scripts/react-parity/verify-ag-ui-client-tools.mjs
node scripts/react-parity/verify-ag-ui-candidate.mjs
```

The verifier emits 20 backend modules plus the direct forwarding entry, with
21 corresponding declarations. It packs into its own temporary directory and
The verifier emits the exact reviewed set of 22 backend modules plus the direct
forwarding entry, with 23 corresponding declarations. It packs into its own temporary directory and
installs real core, React, Angular and backend tarballs. The existing APF output
and framework-only SDK installation ban remain separate required checks. There
is no output rewrite, source alias, narrowed declaration or backend wrapper.
Expand All @@ -37,6 +41,34 @@ and disposal. Strict NodeNext and browser checks use the full installed
declarations with `skipLibCheck: false`. Framework bindings retain the native
snapshot type. Core is required by those declarations; its implementation need
not occur in the backend's runtime graph.
The sole additional bare subpath is a type-only `@threadplane/core/tools`
declaration dependency resolved from the actual packed core tools entry.
The candidate retains exactly two runtime exports, `createSession` and
`projectTextTranscript`; its client-tool session, snapshot, call and blocker
types forward directly from the native runtime modules. Heterogeneous authored
handler argument/result contracts are verified under NodeNext and Bundler,
including readonly projections, an empty catalog and the original no-catalog
snapshot contract.

The installed client-tools proof uses `createSession(options, catalog)` against
an owned loopback server and the actual canonical compiled Python graph,
repository middleware and locked adapter. Only the model is replaced with an
offline scripted model. In separate nonstream and stream worker lifetimes, one
human submission causes two distinct physical runs, one handler invocation,
an exact authored result carried into the real model, a final answer and an
exact native result echo. Snapshot-only calls and streamed argument repair are
verified from genuine adapter events. Unknown calls, held-terminal cancellation
and noncooperative handler cancellation are separate negative scenarios. Each
receipt records sources, module identities, exact Python patch and package
versions, denied socket/DNS operations and owned resource closure.

The Library CI job uses its existing Python 3.12 setup and performs the frozen
client-tools sync before the scoped worker tests and full candidate proof.
`uv run --frozen --no-sync` deliberately relies on that completed sync. To verify
a clean environment locally, choose a new empty directory for
`THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT` and repeat the sync and checks above.
The fixture accepts any Python 3.12 patch and records the actual patch in its
provenance. It requires no provider credentials or paid model requests.

The installed browser uses the same application and independent HTTP oracle as
the original native review: all 13 controls, two owners, both frameworks, child
Expand Down Expand Up @@ -69,8 +101,9 @@ build hashes and installed artifact hashes describe different stages: npm can
omit build-only metadata such as `.npmignore`. Installed bytes are compared to
the actual unpacked tarball, not to an assumed identical dist directory.

This does not establish hosted model access, universal provider support, durable
cancellation, persistence, history loading, reconnect, client-tool execution, SSR
or hydration. The source review and source Mastra executables remain separate
This establishes the specified local native client-tool execution and automatic
continuation with an offline scripted model. It does not establish hosted model
access, universal provider support, durable cancellation or execution, persistence,
history loading, reconnect, SSR or hydration. The source review and source Mastra executables remain separate
required proofs; they alone do not prove candidate installation. Final public
selector placement, backend root migration and releases remain separate work.
4 changes: 4 additions & 0 deletions fixtures/react-parity/ag-ui-candidate/backend-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ export { createSession, projectTextTranscript } from '@threadplane/ag-ui';
export type {
Session,
SessionOptions,
ClientToolSession,
ClientToolSessionSnapshot,
ClientToolCall,
ClientToolBlocker,
TextTranscriptRow,
} from '@threadplane/ag-ui';
6 changes: 6 additions & 0 deletions fixtures/react-parity/ag-ui-candidate/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ export { createSession } from '../../../libs/ag-ui/src/runtime/create-session.js
export type {
Session,
SessionOptions,
ClientToolSession,
} from '../../../libs/ag-ui/src/runtime/create-session.js';
export type {
ClientToolSessionSnapshot,
ClientToolCall,
ClientToolBlocker,
} from '../../../libs/ag-ui/src/runtime/client-tools.js';
export { projectTextTranscript } from '../../../libs/ag-ui/src/runtime/text-transcript.js';
export type { TextTranscriptRow } from '../../../libs/ag-ui/src/runtime/text-transcript.js';
71 changes: 71 additions & 0 deletions fixtures/react-parity/ag-ui-candidate/installed-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import {
type Session,
type SessionOptions,
type TextTranscriptRow,
type ClientToolSession,
type ClientToolSessionSnapshot,
type ClientToolCall,
type ClientToolBlocker,
} from '@threadplane/ag-ui';
import type { CheckedTools, ToolContracts } from '@threadplane/core/tools';
import type { CompleteOutcome } from '@threadplane/core';
export type NativeResponse = Parameters<Session['resume']>[1][number];
export function checkCandidate(signal: AbortSignal) {
const options: SessionOptions = {
Expand Down Expand Up @@ -67,3 +73,68 @@ export function checkCandidate(signal: AbortSignal) {
void session.addToolResult('id', 'result');
return { session, snapshot, rows, stable };
}
export function checkClientTools(options: SessionOptions) {
const catalog = {
get_weather: {
description: 'Weather',
handler: (args: { location: string; nested: { values: number[] } }) => ({
temperatures: [args.location.length],
}),
},
book: {
description: 'Book',
handler: async (args: { seats: number }) => ({
confirmation: String(args.seats),
}),
},
};
const checked = catalog satisfies CheckedTools<typeof catalog>;
const owner: ClientToolSession<ToolContracts<typeof catalog>> = createSession(
options,
checked
);
const snapshot: ClientToolSessionSnapshot<ToolContracts<typeof catalog>> =
owner.getSnapshot();
const calls: readonly ClientToolCall<ToolContracts<typeof catalog>>[] =
snapshot.clientTools.calls;
const outcome: CompleteOutcome | undefined =
snapshot.clientTools.commandOutcome;
const blocker: ClientToolBlocker | undefined = snapshot.clientTools.blocker;
void outcome;
void blocker;
for (const call of calls) {
const name: 'get_weather' | 'book' = call.name;
void name;
if (call.name === 'get_weather') {
const location: string | undefined = call.args?.location;
const temperatures: readonly number[] | undefined =
call.result?.temperatures;
void location;
void temperatures;
// @ts-expect-error Heterogeneous results stay narrowed.
call.result?.confirmation;
// @ts-expect-error Nested arguments are deeply readonly.
call.args?.nested.values.push(1);
// @ts-expect-error Nested results are deeply readonly.
call.result?.temperatures.push(1);
} else {
const seats: number | undefined = call.args?.seats;
const confirmation: string | undefined = call.result?.confirmation;
void seats;
void confirmation;
// @ts-expect-error Names determine authored argument contracts.
call.args?.location;
}
// @ts-expect-error Local evidence is readonly.
call.delivery = 'observed';
}
const empty = createSession(options, {});
const impossible: readonly never[] = empty.getSnapshot().clientTools.calls;
void impossible;
void empty.getSnapshot().clientTools.blocker;
// @ts-expect-error No catalog keeps the original exact snapshot.
createSession(options).getSnapshot().clientTools;
// @ts-expect-error Native snapshots do not manufacture core aggregates.
snapshot.messages;
return owner;
}
127 changes: 127 additions & 0 deletions fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Bounded offline oracle: actual canonical graph, middleware and locked adapter.

Only the canonical module's _base_llm is replaced. This worker never generates
AG-UI events itself: every event is normalized from LangGraphAgent.run.
"""
import asyncio
import hashlib
import importlib
import importlib.metadata
import json
import os
from pathlib import Path
import socket
import sys
import traceback

ROOT = Path(__file__).resolve().parents[3]
MAX_BYTES = 2_000_000
MAX_REQUESTS = 12
os.environ['OPENAI_API_KEY'] = 'local-scripted-model-unused'
os.environ['LANGSMITH_TRACING'] = 'false'
os.environ['LANGCHAIN_TRACING_V2'] = 'false'
for key in list(os.environ):
if key.endswith('_API_KEY') and key != 'OPENAI_API_KEY' or key in ['LANGSMITH_ENDPOINT', 'LANGCHAIN_ENDPOINT']:
os.environ.pop(key, None)
socket_attempts = []
blocked_socket_constructions = []

def deny_socket(self, address, *args):
socket_attempts.append(str(address))
raise RuntimeError('External sockets forbidden in offline canonical worker')

socket.socket.connect = deny_socket
socket.socket.connect_ex = deny_socket
socket.socket.sendto = deny_socket
def audit_network(event, args):
if event == 'socket.__new__' and args[1] in [socket.AF_INET, socket.AF_INET6]:
blocked_socket_constructions.append({'event': event, 'family': int(args[1]), 'stack': [f'{frame.filename}:{frame.lineno}:{frame.name}' for frame in traceback.extract_stack(limit=8)]})
raise RuntimeError('Internet socket construction forbidden in offline canonical worker')
if event in ['socket.connect', 'socket.getaddrinfo', 'socket.gethostbyname', 'socket.gethostbyaddr', 'socket.getnameinfo', 'socket.sendto', 'socket.sendmsg']:
socket_attempts.append(event)
raise RuntimeError('External socket operations and DNS forbidden in offline canonical worker')

sys.addaudithook(audit_network)
sys.path.insert(0, str(ROOT / 'packages/threadplane-middleware/src'))
sys.path.insert(0, str(ROOT / 'cockpit/ag-ui/client-tools/python'))

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from pydantic import Field
from ag_ui.core import RunAgentInput
from ag_ui_langgraph import LangGraphAgent

class ScriptedModel(BaseChatModel):
catalogs: list = Field(default_factory=list)
observed_messages: list = Field(default_factory=list)
call_name: str = 'get_weather'

@property
def _llm_type(self):
return 'offline-canonical-client-tools-fixture'

def bind_tools(self, tools, **kwargs):
self.catalogs.append(tools)
return self

def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self.observed_messages.append([m.model_dump(mode='json') for m in messages])
answer = AIMessage(id='scripted-final-answer', content='Scripted weather receipt received.') if any(isinstance(m, ToolMessage) for m in messages) else AIMessage(id='scripted-weather-call', content='', tool_calls=[{'id': 'scripted-weather-call-id', 'name': self.call_name, 'args': {'location': 'KASE'}, 'type': 'tool_call'}])
return ChatResult(generations=[ChatGeneration(message=answer)])

class ScriptedStreamingModel(ScriptedModel):
async def _astream(self, messages, stop=None, run_manager=None, **kwargs):
self.observed_messages.append([m.model_dump(mode='json') for m in messages])
if any(isinstance(m, ToolMessage) for m in messages):
yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-final-answer', content='Scripted weather receipt received.'))
else:
yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', tool_call_chunks=[{'id': 'scripted-weather-call-id', 'name': self.call_name, 'args': '{"location":', 'index': 0}]))
yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', tool_call_chunks=[{'id': None, 'name': None, 'args': '"KASE"}', 'index': 0}]))
yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', response_metadata={'finish_reason': 'tool_calls'}))

def emit(value):
encoded = json.dumps(value, separators=(',', ':'))
if len(encoded.encode()) > MAX_BYTES:
raise RuntimeError('worker output bound exceeded')
print(encoded, flush=True)

async def main():
assert sys.version_info[:2] == (3, 12), 'Canonical fixture requires actual Python 3.12'
mode = sys.argv[1] if len(sys.argv) > 1 else 'nonstream'
assert mode in ['nonstream', 'stream', 'unknown']
module = importlib.import_module('src.graph')
model = ScriptedStreamingModel() if mode == 'stream' else ScriptedModel(call_name='unknown_weather' if mode == 'unknown' else 'get_weather')
module._base_llm = model
graph = module.build_client_tools_graph()
agent = LangGraphAgent(name='offline-canonical-client-tools', graph=graph)
paths = [Path(__file__), ROOT / 'cockpit/ag-ui/client-tools/python/src/graph.py', ROOT / 'cockpit/ag-ui/client-tools/python/prompts/client-tools.md', ROOT / 'cockpit/ag-ui/client-tools/python/uv.lock', ROOT / 'cockpit/ag-ui/client-tools/python/pyproject.toml']
modules = {name: str(Path(sys.modules[name].__file__).resolve()) for name in ['src.graph', 'ag_ui_langgraph.agent', 'threadplane.middleware.langgraph.middleware']}
paths.extend(Path(value) for value in modules.values())
identity = {'pid': os.getpid(), 'python': sys.version.split()[0], 'mode': mode, 'modules': modules, 'socketAttempts': socket_attempts, 'blockedSocketConstructions': blocked_socket_constructions, 'versions': {name: importlib.metadata.version(name) for name in ['ag-ui-langgraph', 'ag-ui-protocol', 'langgraph', 'langchain-core']}, 'sources': [{'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in dict.fromkeys(paths)]}
emit({'ready': identity})
count = 0
while True:
line = await asyncio.to_thread(sys.stdin.buffer.readline, MAX_BYTES + 1)
if not line:
break
count += 1
try:
if len(line) > MAX_BYTES or not line.endswith(b'\n') or count > MAX_REQUESTS:
raise RuntimeError('worker request bound exceeded')
input = RunAgentInput.model_validate(json.loads(line))
async def collect():
events = []
async for event in agent.run(input):
if len(events) >= 256:
raise RuntimeError('worker event bound exceeded')
events.append(event.model_dump(mode='json', by_alias=True, exclude_none=True))
return events
events = await asyncio.wait_for(collect(), timeout=15)
emit({'events': events, 'observations': {'requestCount': count, 'workerPid': os.getpid(), 'graphIdentity': id(graph), 'checkpointerIdentity': id(graph.checkpointer), 'modelIdentity': id(model), 'modelCatalogs': model.catalogs, 'modelMessages': model.observed_messages, 'socketAttempts': socket_attempts}})
except Exception as error:
emit({'error': str(error), 'socketAttempts': socket_attempts})
break

if __name__ == '__main__':
asyncio.run(main())
Loading
Loading