Merge branch 'master' into docs

This commit is contained in:
Alex Jenkins 2026-04-10 23:10:52 -04:00 committed by GitHub
commit ae58fa7f98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
270 changed files with 19639 additions and 4087 deletions

View file

@ -35,6 +35,7 @@ tg-delete-kg-core = "trustgraph.cli.delete_kg_core:main"
tg-delete-tool = "trustgraph.cli.delete_tool:main"
tg-dump-msgpack = "trustgraph.cli.dump_msgpack:main"
tg-dump-queues = "trustgraph.cli.dump_queues:main"
tg-monitor-prompts = "trustgraph.cli.monitor_prompts:main"
tg-get-flow-blueprint = "trustgraph.cli.get_flow_blueprint:main"
tg-get-kg-core = "trustgraph.cli.get_kg_core:main"
tg-get-document-content = "trustgraph.cli.get_document_content:main"
@ -50,6 +51,7 @@ tg-invoke-document-embeddings = "trustgraph.cli.invoke_document_embeddings:main"
tg-invoke-mcp-tool = "trustgraph.cli.invoke_mcp_tool:main"
tg-invoke-nlp-query = "trustgraph.cli.invoke_nlp_query:main"
tg-invoke-rows-query = "trustgraph.cli.invoke_rows_query:main"
tg-invoke-sparql-query = "trustgraph.cli.invoke_sparql_query:main"
tg-invoke-row-embeddings = "trustgraph.cli.invoke_row_embeddings:main"
tg-invoke-prompt = "trustgraph.cli.invoke_prompt:main"
tg-invoke-structured-query = "trustgraph.cli.invoke_structured_query:main"

View file

@ -8,8 +8,6 @@ message flows, diagnosing stuck services, and understanding system behavior.
Uses TrustGraph's Subscriber abstraction for future-proof pub/sub compatibility.
"""
import pulsar
from pulsar.schema import BytesSchema
import sys
import json
import asyncio
@ -17,45 +15,69 @@ from datetime import datetime
import argparse
from trustgraph.base.subscriber import Subscriber
from trustgraph.base.pubsub import get_pubsub
from trustgraph.base.pubsub import get_pubsub, add_pubsub_args
def decode_json_strings(obj):
"""Recursively decode JSON-encoded string values within a dict/list."""
if isinstance(obj, dict):
return {k: decode_json_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [decode_json_strings(v) for v in obj]
if isinstance(obj, str):
try:
parsed = json.loads(obj)
if isinstance(parsed, (dict, list)):
return decode_json_strings(parsed)
except (json.JSONDecodeError, TypeError):
pass
return obj
def to_dict(value):
"""Recursively convert a value to a JSON-serialisable structure."""
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, bytes):
value = value.decode('utf-8')
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
if isinstance(value, dict):
return {k: to_dict(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [to_dict(v) for v in value]
# Pulsar schema objects expose fields via __dict__
if hasattr(value, '__dict__'):
return {
k: to_dict(v) for k, v in value.__dict__.items()
if not k.startswith('_')
}
return str(value)
def format_message(queue_name, msg):
"""Format a message with timestamp and queue name."""
timestamp = datetime.now().isoformat()
# Try to parse as JSON and pretty-print
try:
# Handle both Message objects and raw bytes
if hasattr(msg, 'value'):
# Message object with .value() method
value = msg.value()
else:
# Raw bytes from schema-less subscription
value = msg
value = msg.value() if hasattr(msg, 'value') else msg
parsed = to_dict(value)
# If it's bytes, decode it
if isinstance(value, bytes):
value = value.decode('utf-8')
# If it's a string, try to parse as JSON
if isinstance(value, str):
try:
parsed = json.loads(value)
body = json.dumps(parsed, indent=2)
except (json.JSONDecodeError, TypeError):
body = value
# Unwrap nested JSON strings (e.g. terms values)
if isinstance(parsed, (dict, list)):
parsed = decode_json_strings(parsed)
body = json.dumps(parsed, indent=2, default=str)
else:
# Try to convert to dict for pretty printing
try:
# Pulsar schema objects have __dict__ or similar
if hasattr(value, '__dict__'):
parsed = {k: v for k, v in value.__dict__.items()
if not k.startswith('_')}
else:
parsed = str(value)
body = json.dumps(parsed, indent=2, default=str)
except (TypeError, AttributeError):
body = str(value)
body = str(parsed)
except Exception as e:
body = f"<Error formatting message: {e}>\n{str(msg)}"
@ -148,15 +170,13 @@ async def log_writer(central_queue, file_handle, shutdown_event, console_output=
break
async def async_main(queues, output_file, pulsar_host, listener_name, subscriber_name, append_mode):
async def async_main(queues, output_file, subscriber_name, append_mode, **pubsub_config):
"""
Main async function to monitor multiple queues concurrently.
Args:
queues: List of queue names to monitor
output_file: Path to output file
pulsar_host: Pulsar connection URL
listener_name: Pulsar listener name
subscriber_name: Base name for subscribers
append_mode: Whether to append to existing file
"""
@ -170,9 +190,9 @@ async def async_main(queues, output_file, pulsar_host, listener_name, subscriber
# Create backend connection
try:
backend = get_pubsub(pulsar_host=pulsar_host, pulsar_listener=listener_name, pubsub_backend='pulsar')
backend = get_pubsub(**pubsub_config)
except Exception as e:
print(f"Error connecting to backend at {pulsar_host}: {e}", file=sys.stderr)
print(f"Error connecting to backend: {e}", file=sys.stderr)
sys.exit(1)
# Create Subscribers and central queue
@ -267,25 +287,20 @@ def main():
description='Monitor and dump messages from multiple Pulsar queues',
epilog="""
Examples:
# Monitor agent and prompt queues
tg-dump-queues non-persistent://tg/request/agent:default \\
non-persistent://tg/request/prompt:default
# Monitor agent and prompt flow queues
tg-dump-queues flow:tg:agent-request:default \\
flow:tg:prompt-request:default
# Monitor with custom output file
tg-dump-queues non-persistent://tg/request/agent:default \\
tg-dump-queues flow:tg:agent-request:default \\
--output debug.log
# Append to existing log file
tg-dump-queues non-persistent://tg/request/agent:default \\
tg-dump-queues flow:tg:agent-request:default \\
--output queue.log --append
Common queue patterns:
- Agent requests: non-persistent://tg/request/agent:default
- Agent responses: non-persistent://tg/response/agent:default
- Prompt requests: non-persistent://tg/request/prompt:default
- Prompt responses: non-persistent://tg/response/prompt:default
- LLM requests: non-persistent://tg/request/text-completion:default
- LLM responses: non-persistent://tg/response/text-completion:default
# Raw Pulsar URIs also accepted
tg-dump-queues persistent://tg/flow/agent-request:default
IMPORTANT:
This tool subscribes to queues without a schema (schema-less mode). To avoid
@ -316,17 +331,7 @@ IMPORTANT:
help='Append to output file instead of overwriting'
)
parser.add_argument(
'--pulsar-host',
default='pulsar://localhost:6650',
help='Pulsar host URL (default: pulsar://localhost:6650)'
)
parser.add_argument(
'--listener-name',
default='localhost',
help='Pulsar listener name (default: localhost)'
)
add_pubsub_args(parser, standalone=True)
parser.add_argument(
'--subscriber',
@ -347,10 +352,10 @@ IMPORTANT:
asyncio.run(async_main(
queues=queues,
output_file=args.output,
pulsar_host=args.pulsar_host,
listener_name=args.listener_name,
subscriber_name=args.subscriber,
append_mode=args.append
append_mode=args.append,
**{k: v for k, v in vars(args).items()
if k not in ('queues', 'output', 'subscriber', 'append')},
))
except KeyboardInterrupt:
# Already handled in async_main

View file

@ -1,5 +1,8 @@
"""
Initialises Pulsar with Trustgraph tenant / namespaces & policy.
Initialises TrustGraph pub/sub infrastructure and pushes initial config.
For Pulsar: creates tenant, namespaces, and retention policies.
For RabbitMQ: queues are auto-declared, so only config push is needed.
"""
import requests
@ -8,10 +11,11 @@ import argparse
import json
from trustgraph.clients.config_client import ConfigClient
from trustgraph.base.pubsub import add_pubsub_args
default_pulsar_admin_url = "http://pulsar:8080"
default_pulsar_host = "pulsar://pulsar:6650"
subscriber = "tg-init-pulsar"
subscriber = "tg-init-pubsub"
def get_clusters(url):
@ -65,12 +69,11 @@ def ensure_namespace(url, tenant, namespace, config):
print(f"Namespace {tenant}/{namespace} created.", flush=True)
def ensure_config(config, pulsar_host, pulsar_api_key):
def ensure_config(config, **pubsub_config):
cli = ConfigClient(
subscriber=subscriber,
pulsar_host=pulsar_host,
pulsar_api_key=pulsar_api_key,
**pubsub_config,
)
while True:
@ -115,11 +118,9 @@ def ensure_config(config, pulsar_host, pulsar_api_key):
time.sleep(2)
print("Retrying...", flush=True)
continue
def init(
pulsar_admin_url, pulsar_host, pulsar_api_key, tenant,
config, config_file,
):
def init_pulsar(pulsar_admin_url, tenant):
"""Pulsar-specific setup: create tenant, namespaces, retention policies."""
clusters = get_clusters(pulsar_admin_url)
@ -137,25 +138,29 @@ def init(
}
})
ensure_namespace(pulsar_admin_url, tenant, "config", {
ensure_namespace(pulsar_admin_url, tenant, "notify", {
"retention_policies": {
"retentionSizeInMB": 10,
"retentionTimeInMinutes": -1,
"retentionSizeInMB": -1,
"retentionTimeInMinutes": 3,
"subscriptionExpirationTimeMinutes": 5,
}
})
if config is not None:
def push_config(config_json, config_file, **pubsub_config):
"""Push initial config if provided."""
if config_json is not None:
try:
print("Decoding config...", flush=True)
dec = json.loads(config)
dec = json.loads(config_json)
print("Decoded.", flush=True)
except Exception as e:
print("Exception:", e, flush=True)
raise e
ensure_config(dec, pulsar_host, pulsar_api_key)
ensure_config(dec, **pubsub_config)
elif config_file is not None:
@ -167,11 +172,12 @@ def init(
print("Exception:", e, flush=True)
raise e
ensure_config(dec, pulsar_host, pulsar_api_key)
ensure_config(dec, **pubsub_config)
else:
print("No config to update.", flush=True)
def main():
parser = argparse.ArgumentParser(
@ -180,22 +186,11 @@ def main():
)
parser.add_argument(
'-p', '--pulsar-admin-url',
'--pulsar-admin-url',
default=default_pulsar_admin_url,
help=f'Pulsar admin URL (default: {default_pulsar_admin_url})',
)
parser.add_argument(
'--pulsar-host',
default=default_pulsar_host,
help=f'Pulsar host (default: {default_pulsar_host})',
)
parser.add_argument(
'--pulsar-api-key',
help=f'Pulsar API key',
)
parser.add_argument(
'-c', '--config',
help=f'Initial configuration to load',
@ -212,18 +207,43 @@ def main():
help=f'Tenant (default: tg)',
)
add_pubsub_args(parser)
args = parser.parse_args()
backend_type = args.pubsub_backend
# Extract pubsub config from args
pubsub_config = {
k: v for k, v in vars(args).items()
if k not in ('pulsar_admin_url', 'config', 'config_file', 'tenant')
}
while True:
try:
print(flush=True)
print(
f"Initialising with Pulsar {args.pulsar_admin_url}...",
flush=True
# Pulsar-specific setup (tenants, namespaces)
if backend_type == 'pulsar':
print(flush=True)
print(
f"Initialising Pulsar at {args.pulsar_admin_url}...",
flush=True,
)
init_pulsar(args.pulsar_admin_url, args.tenant)
else:
print(flush=True)
print(
f"Using {backend_type} backend (no admin setup needed).",
flush=True,
)
# Push config (works with any backend)
push_config(
args.config, args.config_file,
**pubsub_config,
)
init(**vars(args))
print("Initialisation complete.", flush=True)
break
@ -236,4 +256,4 @@ def main():
print("Will retry...", flush=True)
if __name__ == "__main__":
main()
main()

View file

@ -12,7 +12,13 @@ from trustgraph.api import (
ProvenanceEvent,
Question,
Analysis,
Observation,
Conclusion,
Decomposition,
Finding,
Plan,
StepResult,
Synthesis,
AgentThought,
AgentObservation,
AgentAnswer,
@ -176,16 +182,18 @@ def question_explainable(
print(item.content, end="", flush=True)
elif isinstance(item, ProvenanceEvent):
# Process provenance event immediately
# Use inline entity if available, otherwise fetch from graph
prov_id = item.explain_id
explain_graph = item.explain_graph or "urn:graph:retrieval"
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
entity = item.entity
if entity is None:
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
if entity is None:
if debug:
@ -201,13 +209,42 @@ def question_explainable(
print(f" Time: {entity.timestamp}", file=sys.stderr)
elif isinstance(entity, Analysis):
print(f"\n [iteration] {prov_id}", file=sys.stderr)
if entity.action:
print(f" Action: {entity.action}", file=sys.stderr)
if entity.thought:
print(f" Thought: {entity.thought}", file=sys.stderr)
if entity.observation:
print(f" Observation: {entity.observation}", file=sys.stderr)
action_label = f": {entity.action}" if entity.action else ""
print(f"\n [analysis{action_label}] {prov_id}", file=sys.stderr)
elif isinstance(entity, Observation):
print(f"\n [observation] {prov_id}", file=sys.stderr)
if entity.document:
print(f" Document: {entity.document}", file=sys.stderr)
elif isinstance(entity, Decomposition):
print(f"\n [decompose] {prov_id}", file=sys.stderr)
for i, goal in enumerate(entity.goals):
print(f" Thread {i}: {goal}", file=sys.stderr)
elif isinstance(entity, Finding):
print(f"\n [finding] {prov_id}", file=sys.stderr)
if entity.goal:
print(f" Goal: {entity.goal}", file=sys.stderr)
if entity.document:
print(f" Document: {entity.document}", file=sys.stderr)
elif isinstance(entity, Plan):
print(f"\n [plan] {prov_id}", file=sys.stderr)
for i, step in enumerate(entity.steps):
print(f" Step {i}: {step}", file=sys.stderr)
elif isinstance(entity, StepResult):
print(f"\n [step-result] {prov_id}", file=sys.stderr)
if entity.step:
print(f" Step: {entity.step}", file=sys.stderr)
if entity.document:
print(f" Document: {entity.document}", file=sys.stderr)
elif isinstance(entity, Synthesis):
print(f"\n [synthesis] {prov_id}", file=sys.stderr)
if entity.document:
print(f" Document: {entity.document}", file=sys.stderr)
elif isinstance(entity, Conclusion):
print(f"\n [conclusion] {prov_id}", file=sys.stderr)
@ -233,7 +270,8 @@ def question_explainable(
def question(
url, question, flow_id, user, collection,
plan=None, state=None, group=None, verbose=False, streaming=True,
plan=None, state=None, group=None, pattern=None,
verbose=False, streaming=True,
token=None, explainable=False, debug=False
):
# Explainable mode uses the API to capture and process provenance events
@ -273,6 +311,8 @@ def question(
request_params["state"] = state
if group is not None:
request_params["group"] = group
if pattern is not None:
request_params["pattern"] = pattern
try:
# Call agent
@ -396,6 +436,12 @@ def main():
help=f'Agent plan (default: unspecified)'
)
parser.add_argument(
'-p', '--pattern',
choices=['react', 'plan-then-execute', 'supervisor'],
help='Force execution pattern (default: auto-selected by meta-router)'
)
parser.add_argument(
'-s', '--state',
help=f'Agent initial state (default: unspecified)'
@ -444,6 +490,7 @@ def main():
plan = args.plan,
state = args.state,
group = args.group,
pattern = args.pattern,
verbose = args.verbose,
streaming = not args.no_streaming,
token = args.token,

View file

@ -45,16 +45,18 @@ def question_explainable(
print(item.content, end="", flush=True)
elif isinstance(item, ProvenanceEvent):
# Process provenance event immediately
# Use inline entity if available, otherwise fetch from graph
prov_id = item.explain_id
explain_graph = item.explain_graph or "urn:graph:retrieval"
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
entity = item.entity
if entity is None:
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
if entity is None:
if debug:

View file

@ -667,16 +667,18 @@ def _question_explainable_api(
print(item.content, end="", flush=True)
elif isinstance(item, ProvenanceEvent):
# Process provenance event immediately
# Use inline entity if available, otherwise fetch from graph
prov_id = item.explain_id
explain_graph = item.explain_graph or "urn:graph:retrieval"
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
entity = item.entity
if entity is None:
entity = explain_client.fetch_entity(
prov_id,
graph=explain_graph,
user=user,
collection=collection
)
if entity is None:
if debug:

View file

@ -0,0 +1,239 @@
"""
Execute a SPARQL query against the TrustGraph knowledge graph.
"""
import argparse
import os
import json
import sys
from trustgraph.api import Api
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
default_user = 'trustgraph'
default_collection = 'default'
def _term_cell(val):
"""Extract display string from a wire-format term."""
if val is None:
return ""
t = val.get("t", "")
if t == "i":
return val.get("i", "")
elif t == "l":
return val.get("v", "")
return val.get("v", val.get("i", ""))
def _term_str(val):
"""Convert a wire-format term to a Turtle-style display string."""
if val is None:
return "?"
t = val.get("t", "")
if t == "i":
return f"<{val.get('i', '')}>"
elif t == "l":
v = val.get("v", "")
dt = val.get("d", "")
lang = val.get("l", "")
if lang:
return f'"{v}"@{lang}'
elif dt:
return f'"{v}"^^<{dt}>'
return f'"{v}"'
return str(val)
def sparql_query(url, token, flow_id, query, user, collection, limit,
batch_size, output_format):
socket = Api(url=url, token=token).socket()
flow = socket.flow(flow_id)
variables = None
all_rows = []
try:
for response in flow.sparql_query_stream(
query=query,
user=user,
collection=collection,
limit=limit,
batch_size=batch_size,
):
query_type = response.get("query-type", "select")
# ASK queries - just print and return
if query_type == "ask":
print("true" if response.get("ask-result") else "false")
return
# CONSTRUCT/DESCRIBE - print triples
if query_type in ("construct", "describe"):
triples = response.get("triples", [])
if not triples:
print("No triples.")
elif output_format == "json":
print(json.dumps(triples, indent=2))
else:
for t in triples:
s = _term_str(t.get("s"))
p = _term_str(t.get("p"))
o = _term_str(t.get("o"))
print(f"{s} {p} {o} .")
return
# SELECT - accumulate bindings across batches
if variables is None:
variables = response.get("variables", [])
bindings = response.get("bindings", [])
for binding in bindings:
values = binding.get("values", [])
all_rows.append([_term_cell(v) for v in values])
# Output SELECT results
if variables is None:
print("No results.")
return
if not all_rows:
print("No results.")
return
if output_format == "json":
rows = []
for row in all_rows:
rows.append({
var: cell for var, cell in zip(variables, row)
})
print(json.dumps(rows, indent=2))
else:
# Table format
col_widths = [len(v) for v in variables]
for row in all_rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(cell))
header = " | ".join(
v.ljust(col_widths[i]) for i, v in enumerate(variables)
)
separator = "-+-".join("-" * w for w in col_widths)
print(header)
print(separator)
for row in all_rows:
line = " | ".join(
cell.ljust(col_widths[i]) if i < len(col_widths) else cell
for i, cell in enumerate(row)
)
print(line)
finally:
socket.close()
def main():
parser = argparse.ArgumentParser(
prog='tg-invoke-sparql-query',
description=__doc__,
)
parser.add_argument(
'-u', '--url',
default=default_url,
help=f'API URL (default: {default_url})',
)
parser.add_argument(
'-t', '--token',
default=os.getenv("TRUSTGRAPH_TOKEN"),
help='API bearer token (default: TRUSTGRAPH_TOKEN env var)',
)
parser.add_argument(
'-f', '--flow-id',
default="default",
help='Flow ID (default: default)',
)
parser.add_argument(
'-q', '--query',
help='SPARQL query string',
)
parser.add_argument(
'-i', '--input',
help='Read SPARQL query from file (use - for stdin)',
)
parser.add_argument(
'-U', '--user',
default=default_user,
help=f'User ID (default: {default_user})',
)
parser.add_argument(
'-C', '--collection',
default=default_collection,
help=f'Collection ID (default: {default_collection})',
)
parser.add_argument(
'-l', '--limit',
type=int,
default=10000,
help='Result limit (default: 10000)',
)
parser.add_argument(
'-b', '--batch-size',
type=int,
default=20,
help='Streaming batch size (default: 20)',
)
parser.add_argument(
'--format',
choices=['table', 'json'],
default='table',
help='Output format (default: table)',
)
args = parser.parse_args()
# Get query from argument or file
query = args.query
if not query and args.input:
if args.input == '-':
query = sys.stdin.read()
else:
with open(args.input) as f:
query = f.read()
if not query:
parser.error("Either -q/--query or -i/--input is required")
try:
sparql_query(
url=args.url,
token=args.token,
flow_id=args.flow_id,
query=query,
user=args.user,
collection=args.collection,
limit=args.limit,
batch_size=args.batch_size,
output_format=args.format,
)
except Exception as e:
print(f"Exception: {e}", flush=True, file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,328 @@
"""
Monitor prompt request/response queues and log activity with timing.
Subscribes to prompt request and response queues, correlates
them by message ID, and logs a summary of each request/response with
elapsed time. Streaming responses are accumulated and shown once at
completion.
Examples:
tg-monitor-prompts
tg-monitor-prompts --flow default --max-lines 5
tg-monitor-prompts --queue-type prompt-rag
"""
import json
import asyncio
import sys
import argparse
from datetime import datetime
from collections import OrderedDict
from trustgraph.base.pubsub import get_pubsub, add_pubsub_args
default_flow = "default"
default_queue_type = "prompt-rag"
default_max_lines = 3
default_max_width = 80
def truncate_text(text, max_lines, max_width):
"""Truncate text to max_lines lines, each at most max_width chars."""
if not text:
return "(empty)"
lines = text.splitlines()
result = []
for line in lines[:max_lines]:
if len(line) > max_width:
result.append(line[:max_width - 3] + "...")
else:
result.append(line)
if len(lines) > max_lines:
result.append(f" ... ({len(lines) - max_lines} more lines)")
return "\n".join(result)
def summarise_value(value, max_width):
"""Summarise a term value — show type and size for large values."""
# Try to parse JSON
try:
parsed = json.loads(value)
except (json.JSONDecodeError, TypeError):
parsed = value
if isinstance(parsed, list):
return f"[{len(parsed)} items]"
elif isinstance(parsed, dict):
return f"{{{len(parsed)} keys}}"
elif isinstance(parsed, str):
if len(parsed) > max_width:
return parsed[:max_width - 3] + "..."
return parsed
else:
s = str(parsed)
if len(s) > max_width:
return s[:max_width - 3] + "..."
return s
def format_terms(terms, max_lines, max_width):
"""Format prompt terms for display — concise summary."""
if not terms:
return ""
parts = []
for key, value in terms.items():
summary = summarise_value(value, max_width - len(key) - 4)
parts.append(f" {key}: {summary}")
return "\n".join(parts)
def parse_raw_message(msg):
"""Parse a raw message into (correlation_id, body_dict)."""
try:
props = msg.properties()
corr_id = props.get("id", "")
except Exception:
corr_id = ""
try:
value = msg.value()
if isinstance(value, dict):
body = value
elif isinstance(value, bytes):
body = json.loads(value.decode("utf-8"))
elif isinstance(value, str):
body = json.loads(value)
else:
body = {}
except Exception:
body = {}
return corr_id, body
async def monitor(flow, queue_type, max_lines, max_width, **config):
request_queue = f"request:tg:{queue_type}:{flow}"
response_queue = f"response:tg:{queue_type}:{flow}"
print(f"Monitoring prompt queues:")
print(f" Request: {request_queue}")
print(f" Response: {response_queue}")
print(f"Press Ctrl+C to stop\n")
backend = get_pubsub(**config)
req_consumer = backend.create_consumer(
topic=request_queue,
subscription="prompt-monitor-req",
schema=None,
initial_position='latest',
)
resp_consumer = backend.create_consumer(
topic=response_queue,
subscription="prompt-monitor-resp",
schema=None,
initial_position='latest',
)
# Track in-flight requests: corr_id -> (timestamp, template_id)
in_flight = OrderedDict()
# Accumulate streaming responses: corr_id -> list of text chunks
streaming_chunks = {}
print("Listening...\n")
try:
while True:
got_message = False
# Poll request queue
try:
msg = req_consumer.receive(timeout_millis=100)
got_message = True
timestamp = datetime.now()
corr_id, body = parse_raw_message(msg)
time_str = timestamp.strftime("%H:%M:%S.%f")[:-3]
template_id = body.get("id", "(unknown)")
terms = body.get("terms", {})
streaming = body.get("streaming", False)
in_flight[corr_id] = (timestamp, template_id)
# Limit size
while len(in_flight) > 1000:
in_flight.popitem(last=False)
stream_flag = " [streaming]" if streaming else ""
id_display = corr_id[:8] if corr_id else "--------"
print(f"[{time_str}] REQ {id_display} "
f"template={template_id}{stream_flag}")
if terms:
print(format_terms(terms, max_lines, max_width))
req_consumer.acknowledge(msg)
except TimeoutError:
pass
# Poll response queue
try:
msg = resp_consumer.receive(timeout_millis=100)
got_message = True
timestamp = datetime.now()
corr_id, body = parse_raw_message(msg)
time_str = timestamp.strftime("%H:%M:%S.%f")[:-3]
id_display = corr_id[:8] if corr_id else "--------"
error = body.get("error")
text = body.get("text", "")
obj = body.get("object", "")
eos = body.get("end_of_stream", False)
if error:
# Error — show immediately
elapsed_str = ""
if corr_id in in_flight:
req_timestamp, _ = in_flight.pop(corr_id)
elapsed = (timestamp - req_timestamp).total_seconds()
elapsed_str = f" ({elapsed:.3f}s)"
streaming_chunks.pop(corr_id, None)
err_msg = error
if isinstance(error, dict):
err_msg = error.get("message", str(error))
print(f"[{time_str}] ERR {id_display} "
f"{err_msg}{elapsed_str}")
elif eos:
# End of stream — show accumulated text + timing
elapsed_str = ""
if corr_id in in_flight:
req_timestamp, _ = in_flight.pop(corr_id)
elapsed = (timestamp - req_timestamp).total_seconds()
elapsed_str = f" ({elapsed:.3f}s)"
accumulated = streaming_chunks.pop(corr_id, [])
if text:
accumulated.append(text)
full_text = "".join(accumulated)
if full_text:
truncated = truncate_text(
full_text, max_lines, max_width
)
print(f"[{time_str}] RESP {id_display}"
f"{elapsed_str}")
print(f" {truncated}")
else:
print(f"[{time_str}] RESP {id_display}"
f"{elapsed_str} (empty)")
elif text or obj:
# Streaming chunk or non-streaming response
if corr_id in streaming_chunks or (
corr_id in in_flight
):
# Accumulate streaming chunk
if corr_id not in streaming_chunks:
streaming_chunks[corr_id] = []
streaming_chunks[corr_id].append(text or obj)
else:
# Non-streaming single response
elapsed_str = ""
if corr_id in in_flight:
req_timestamp, _ = in_flight.pop(corr_id)
elapsed = (
timestamp - req_timestamp
).total_seconds()
elapsed_str = f" ({elapsed:.3f}s)"
content = text or obj
label = "" if text else " (object)"
truncated = truncate_text(
content, max_lines, max_width
)
print(f"[{time_str}] RESP {id_display}"
f"{label}{elapsed_str}")
print(f" {truncated}")
resp_consumer.acknowledge(msg)
except TimeoutError:
pass
if not got_message:
await asyncio.sleep(0.05)
except KeyboardInterrupt:
print("\nStopping...")
finally:
req_consumer.close()
resp_consumer.close()
backend.close()
def main():
parser = argparse.ArgumentParser(
prog="tg-monitor-prompts",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-f", "--flow",
default=default_flow,
help=f"Flow ID (default: {default_flow})",
)
parser.add_argument(
"-q", "--queue-type",
default=default_queue_type,
help=f"Queue type: prompt or prompt-rag (default: {default_queue_type})",
)
parser.add_argument(
"-l", "--max-lines",
type=int,
default=default_max_lines,
help=f"Max lines of text per term/response (default: {default_max_lines})",
)
parser.add_argument(
"-w", "--max-width",
type=int,
default=default_max_width,
help=f"Max width per line (default: {default_max_width})",
)
add_pubsub_args(parser, standalone=True)
args = parser.parse_args()
try:
asyncio.run(monitor(
flow=args.flow,
queue_type=args.queue_type,
max_lines=args.max_lines,
max_width=args.max_width,
**{k: v for k, v in vars(args).items()
if k not in ('flow', 'queue_type', 'max_lines', 'max_width')},
))
except KeyboardInterrupt:
pass
except Exception as e:
print(f"Fatal error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -26,7 +26,12 @@ from trustgraph.api import (
Focus,
Synthesis,
Analysis,
Observation,
Conclusion,
Decomposition,
Finding,
Plan,
StepResult,
)
default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/')
@ -297,6 +302,23 @@ def print_docrag_text(trace, explain_client, api, user):
print("No synthesis data found")
def _print_document_content(explain_client, api, user, document_uri, label="Answer"):
"""Fetch and print document content, or fall back to URI."""
if not document_uri:
return
content = ""
if api:
content = explain_client.fetch_document_content(
document_uri, api, user
)
if content:
print(f"{label}:")
for line in content.split("\n"):
print(f" {line}")
else:
print(f"Document: {document_uri}")
def print_agent_text(trace, explain_client, api, user):
"""Print Agent trace in text format."""
question = trace.get("question")
@ -310,82 +332,150 @@ def print_agent_text(trace, explain_client, api, user):
print(f"Time: {question.timestamp}")
print()
# Analysis steps
print("--- Analysis ---")
iterations = trace.get("iterations", [])
if iterations:
for i, analysis in enumerate(iterations, 1):
print(f"Analysis {i}:")
print(f" Thought: {analysis.thought or 'N/A'}")
print(f" Action: {analysis.action or 'N/A'}")
# Walk the steps list which contains all entity types
steps = trace.get("steps", [])
for step in steps:
if analysis.arguments:
# Try to pretty-print JSON arguments
if isinstance(step, Decomposition):
print("--- Decomposition ---")
print(f"Decomposed into {len(step.goals)} research threads:")
for i, goal in enumerate(step.goals):
print(f" {i}: {goal}")
print()
elif isinstance(step, Finding):
print("--- Finding ---")
print(f"Goal: {step.goal}")
_print_document_content(
explain_client, api, user, step.document, "Result",
)
print()
elif isinstance(step, Plan):
print("--- Plan ---")
print(f"Plan with {len(step.steps)} steps:")
for i, s in enumerate(step.steps):
print(f" {i}: {s}")
print()
elif isinstance(step, StepResult):
print("--- Step Result ---")
print(f"Step: {step.step}")
_print_document_content(
explain_client, api, user, step.document, "Result",
)
print()
elif isinstance(step, Analysis):
print("--- Analysis ---")
print(f" Action: {step.action or 'N/A'}")
if step.arguments:
try:
args_obj = json.loads(analysis.arguments)
args_obj = json.loads(step.arguments)
args_str = json.dumps(args_obj, indent=4)
print(f" Arguments:")
for line in args_str.split('\n'):
print(f" {line}")
except Exception:
print(f" Arguments: {analysis.arguments}")
else:
print(f" Arguments: N/A")
obs = analysis.observation or 'N/A'
if obs and len(obs) > 200:
obs = obs[:200] + "... [truncated]"
print(f" Observation: {obs}")
print(f" Arguments: {step.arguments}")
print()
else:
print("No analysis steps recorded")
print()
# Conclusion
print("--- Conclusion ---")
conclusion = trace.get("conclusion")
if conclusion:
content = ""
if conclusion.document and api:
content = explain_client.fetch_document_content(
conclusion.document, api, user
elif isinstance(step, Observation):
print("--- Observation ---")
_print_document_content(
explain_client, api, user, step.document, "Content",
)
if content:
print("Answer:")
for line in content.split("\n"):
print(f" {line}")
elif conclusion.document:
print(f"Document: {conclusion.document}")
else:
print("No conclusion recorded")
else:
print("No conclusion recorded")
print()
elif isinstance(step, Synthesis):
print("--- Synthesis ---")
_print_document_content(
explain_client, api, user, step.document, "Answer",
)
print()
elif isinstance(step, Conclusion):
print("--- Conclusion ---")
_print_document_content(
explain_client, api, user, step.document, "Answer",
)
print()
if not steps:
print("No trace steps recorded")
print()
def trace_to_dict(trace, trace_type):
"""Convert trace entities to JSON-serializable dict."""
if trace_type == "agent":
question = trace.get("question")
def _step_to_dict(step):
if isinstance(step, Decomposition):
return {
"type": "decomposition",
"id": step.uri,
"goals": step.goals,
}
elif isinstance(step, Finding):
return {
"type": "finding",
"id": step.uri,
"goal": step.goal,
"document": step.document,
}
elif isinstance(step, Plan):
return {
"type": "plan",
"id": step.uri,
"steps": step.steps,
}
elif isinstance(step, StepResult):
return {
"type": "step-result",
"id": step.uri,
"step": step.step,
"document": step.document,
}
elif isinstance(step, Observation):
return {
"type": "observation",
"id": step.uri,
"document": step.document,
}
elif isinstance(step, Analysis):
return {
"type": "analysis",
"id": step.uri,
"action": step.action,
"arguments": step.arguments,
"thought": step.thought,
}
elif isinstance(step, Synthesis):
return {
"type": "synthesis",
"id": step.uri,
"document": step.document,
}
elif isinstance(step, Conclusion):
return {
"type": "conclusion",
"id": step.uri,
"document": step.document,
}
return {"type": step.entity_type, "id": step.uri}
steps = trace.get("steps", [])
return {
"type": "agent",
"session_id": question.uri if question else None,
"question": question.query if question else None,
"time": question.timestamp if question else None,
"iterations": [
{
"id": a.uri,
"thought": a.thought,
"action": a.action,
"arguments": a.arguments,
"observation": a.observation,
}
for a in trace.get("iterations", [])
],
"conclusion": {
"id": trace["conclusion"].uri,
"document": trace["conclusion"].document,
} if trace.get("conclusion") else None,
"steps": [_step_to_dict(s) for s in steps],
}
elif trace_type == "docrag":
question = trace.get("question")

View file

@ -398,16 +398,11 @@ def main():
print("=" * 60)
print(tr.t("cli.verify_system_status.title"))
print("=" * 60)
# print(f"Global timeout: {args.global_timeout}s")
# print(f"Check timeout: {args.check_timeout}s")
# print(f"Retry delay: {args.retry_delay}s")
# print("=" * 60)
print()
# Phase 1: Infrastructure
print(tr.t("cli.verify_system_status.phase_1"))
print("-" * 60)
if not checker.run_check(
tr.t("cli.verify_system_status.check_name.pulsar"),
check_pulsar,