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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "agentcat"
version = "2.0.2"
version = "2.1.0"
description = "Analytics tool for MCP (Model Context Protocol) servers, Claude Connectors, and ChatGPT Plugins - tracks tool usage patterns and provides insights"
authors = [
{ name = "AgentCat, Inc.", email = "support@agentcat.com" },
Expand Down
23 changes: 21 additions & 2 deletions src/agentcat/modules/adapters/_inner_tap.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
from types import TracebackType
from typing import Any

from agentcat.modules.exceptions import capture_exception
from agentcat.modules.exceptions import _safe_str, capture_exception
from agentcat.modules.logging import write_to_log
from agentcat.types import ErrorData

Expand Down Expand Up @@ -202,12 +202,31 @@ def error(self, flattened: Any) -> ErrorData:
an upstream error a proxy passed through, or a tool that returned
``is_error`` without anything having been raised at all, both of which
have no local exception and never will.

One generation's wrapper is patched up: mcp 2.1 keeps a crash's own
text off the wire by raising ``UnexpectedToolError("Error executing
tool <name>")`` from it, where every earlier generation put
``: <text>`` after the prefix. The cause still carries the text, so
it goes back on the event's message — the wire is not ours to touch —
and the message reads the same on every generation. Matched by class
name (the class exists only where the policy does) and skipped when
the text is already there (a nested crash's wrapper embeds the inner
wrapper's, and a future upstream may embed it again).
"""
# `is not None`, not truthiness: an exception class that defines
# `__len__` or `__bool__` can be falsy, and losing its traceback to
# that would be a very quiet bug.
exc = self._cell.exc
return capture_exception(exc if exc is not None else flattened)
error = capture_exception(exc if exc is not None else flattened)
try:
cause = getattr(exc, "__cause__", None)
if type(exc).__name__ == "UnexpectedToolError" and cause is not None:
text = _safe_str(cause)
if text not in error["message"]:
error["message"] = f"{error['message']}: {text}"
except Exception: # a hostile __cause__ costs the suffix, never the event
pass
return error


def inner_tap() -> InnerTap:
Expand Down
27 changes: 15 additions & 12 deletions src/agentcat/modules/callpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ async def resolve_call(
A tool absent from `declared_session_params` is ours — including one this
instance never listed. That is the common case on stateless HTTP and it
degrades safely: a customer's foreign value in that window is classified
`invalid` rather than `foreign`, and both publish sessionless.
`invalid` rather than `foreign`, and both publish sessionless (a value
spelling `start` instead mints, exactly as on a parameter we injected).
"""
resolution = await resolve_handles(
raw_arguments,
Expand Down Expand Up @@ -157,9 +158,10 @@ async def get_stripped_arguments(
rebuild falls back to the shape+config-aware strip (see
`injected_parameter_names`): a name is removed only when the enabled
options would have injected it AND, for `session_id`, the value matches
our minted shape — a customer-declared parameter rides through to their
handler. The fallback also clears the output-injection registry, so the
structured mirror stops gating on knowledge we no longer have (§3.4b).
our minted shape or the `start` sentinel — a customer-declared parameter
rides through to their handler. The fallback also clears the
output-injection registry, so the structured mirror stops gating on
knowledge we no longer have (§3.4b).
"""
registry = data.injected_params_registry
if registry is None and rebuild is not None:
Expand Down Expand Up @@ -224,19 +226,20 @@ def decorate_content(
res: HandleResolution,
make_text_block: Callable[[str], Any],
) -> list[Any] | None:
"""The trailing mint-back block, or None to leave the result untouched.
"""The leading mint-back block, or None to leave the result untouched.

Error state is deliberately not an input: the retry after a failure has to
carry the same session, so `isError` results decorate on identical terms
(§3.4a). Whether there is anything to say at all stays the single ruling of
`build_mint_back_text` — a session minted on this call, or a supplied one
this server never issued; never in hook mode, and never for a parameter
AgentCat did not inject.
Prepended, not appended: an ID at the tail of a long result is what
clients truncate away. Error state is deliberately not an input: the retry
after a failure has to carry the same session, so `isError` results
decorate on identical terms (§3.4a). Whether there is anything to say at
all stays the single ruling of `build_mint_back_text` — a session minted
on this call, or a supplied one this server never issued; never in hook
mode, and never for a parameter AgentCat did not inject.
"""
text = build_mint_back_text(res)
if text is None or not isinstance(content, list):
return None
return [*content, make_text_block(text)]
return [make_text_block(text), *content]


def structured_mirror(
Expand Down
44 changes: 23 additions & 21 deletions src/agentcat/modules/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
CONTEXT_PARAM = "context"
GET_MORE_TOOLS_NAME = "get_more_tools"
AGENT_ID_PREFIX = "agt" # reserved; server-side agent minting was removed
MCP_INSTRUCTIONS_KEY = "_mcp_instructions"
MCP_SESSION_KEY = "mcp_session"
META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
AGENTCAT_TAG_SESSION_SOURCE = "agentcat_session_id_source"
Expand All @@ -44,27 +44,29 @@
# Source of truth: agentcat-typescript-sdk/src/modules/constants.ts (and the
# copy spec it cites). Do not reword here without changing the TS SDK first;
# tests/test_constants_copy.py enforces byte parity.
SESSION_ID_PARAM_DESCRIPTION = "REQUIRED on every call after your first. This MCP server associates all of your tool calls for a given task using session_id; a call that arrives without it cannot be associated with your earlier work and is treated as the start of an unrelated task, disconnected from the original goal. Omit it on your first call only — the server will issue one in the _mcp_instructions field of the result (or a trailing [MCP INSTRUCTIONS] text block) — then echo that exact value on every later call. Never invent a value, and do not issue parallel tool calls until the server has issued your session_id. One session_id covers the whole goal from start to finish: when you spawn subagents you MUST give them this same session_id, or their work is severed from yours. Without session_id, this server does not function as intended."
AGENT_ID_PARAM_DESCRIPTION = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent working this task using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended."
AGENT_ID_PARAM_DESCRIPTION_HOOK_MODE = "REQUIRED on every call, including your first. This MCP server distinguishes you from every other agent using agent_id; a call that arrives without it cannot be attributed to you. Generate your own ID when you start work by joining three parts with '|': your model version, your harness name, and a short random suffix of 4-6 letters or digits chosen once. Example: opus-4.80-1m|claude-code|k3n9x. Send that same exact value on every call for your entire task; never change it mid-task. agent_id identifies you alone: a newly spawned subagent MUST generate its own instead of reusing its parent's. Without agent_id, this server does not function as intended."
MINT_BACK_HEADER_SESSION = "[MCP INSTRUCTIONS]: session_id issued."
MINT_BACK_HEADER_INVALID = "[MCP INSTRUCTIONS]: session_id not recognized."
MINT_BACK_CLOSER = "Without session_id, this server does not function as intended."
MINT_BACK_INVALID_LINE = " The session_id you sent was not issued by this server. Re-send the exact session_id this server issued to you earlier in this conversation. Never invent a value. If this server has not issued you a session_id yet, omit the parameter and one will be issued."
MCP_INSTRUCTIONS_FIELD_DESCRIPTION = "Your handles for this task, confirmed by this MCP server on every response, and the instructions for echoing them on later calls. Read and follow."
MCP_INSTRUCTIONS_SESSION_ID_DESCRIPTION = (
"Echo this exact value as the session_id argument on every subsequent tool call."
SESSION_ID_PARAM_DESCRIPTION = "Session continuity handle, one of two values: the ses_ ID issued for the task underway, or start. This server cannot link your calls between requests on its own, so session continuity travels in this parameter instead. If you were handed a session_id for this task — for example by the agent that spawned you — send that exact value from your first call. Otherwise send start on your first call; the server will issue an opaque correlation ID in the mcp_session field of the result, or in a text block at the start of the result beginning [session_id issued. Then send that exact ses_ value on every later call and hand it to any subagents working the same task. start always begins a new, unrelated task — never send it mid-task. If you send a value this server does not recognize, the result reports it: mcp_session.status of unrecognized, or a text block beginning [session_id unrecognized; re-send the ID issued for this task, or start if none was issued yet. Never invent a ses_ value."
# Wire literals of the session_id value contract: the pattern the injected
# parameter's schema declares, and the sentinel value that begins a new task.
# The pattern is is_valid_session_id's shape with the start alternative added;
# resolution accepts the sentinel case-insensitively and still treats an
# absent value as a start (stale schemas and scripted callers never error).
SESSION_ID_PARAM_PATTERN = "^(start|ses_[0-9A-Za-z]{27})$"
SESSION_START_SENTINEL = "start"
AGENT_ID_PARAM_DESCRIPTION = "Agent identity handle, required on every call including your first. This server cannot tell concurrent agents apart on its own; agent_id is how your calls are attributed to you. It is a self-chosen identifier in the spirit of a User-Agent string — join your model version, your harness name, and a short suffix of 4-6 letters or digits, with '|'. Example: opus-4.80-1m|claude-code|k3n9x. Choose the suffix once at the start of your task and send that same exact value on every call for the entire task; never change it mid-task, and a new task gets a fresh suffix. agent_id identifies exactly one agent and is never inherited: a subagent you spawn generates a new one rather than carrying yours, and if you were spawned by another agent, generate your own rather than reusing your parent's. A call without agent_id cannot be attributed to you."
MINT_BACK_HEADER_ISSUED = (
"[session_id issued — see this tool's session_id parameter description]"
)
MCP_INSTRUCTIONS_AGENT_ID_DESCRIPTION = "Your agent_id as this server received it. Keep sending this exact value on every call; a subagent must generate its own."
MINT_BACK_ISSUED_BODY = "This is the first-call issuance described in this tool's session_id parameter description."
MINT_BACK_HEADER_UNRECOGNIZED = (
"[session_id unrecognized — see this tool's session_id parameter description]"
)
MINT_BACK_UNRECOGNIZED_BODY = "The value sent was not issued by this server. Re-send the session_id issued earlier for this task; if none was issued yet, send start and one will be issued."
MCP_SESSION_FIELD_DESCRIPTION = "Session continuity and agent attribution state for this task, returned on completed responses that carry structured output. This server cannot link your calls between requests on its own, so session continuity travels here instead."
MCP_SESSION_FIELD_DESCRIPTION_HOOK_MODE = "Agent attribution state for this task, returned on completed responses that carry structured output."
MCP_SESSION_SESSION_ID_DESCRIPTION = "Opaque correlation ID for this task, issued by this server. Use this as the session_id argument of every later call, and hand it to any subagents working the same task. Absent when status is unrecognized; no replacement is issued in that response — recovery is described under status."
MCP_SESSION_AGENT_ID_DESCRIPTION = "Present only when you sent agent_id on this call. Your agent_id, echoed as received. Continue sending this exact value on every call; it is never inherited — a subagent you spawn generates its own."
MCP_SESSION_STATUS_DESCRIPTION = "issued: first call of a task; the session_id above was just created. active: the session_id you sent was accepted; keep sending it. unrecognized: the value sent was not issued by this server — re-send the one issued earlier for this task; if none was issued yet, send start to be issued a new one."


def mint_back_session_line(session_id: str) -> str:
return f" session_id={session_id} — required on every subsequent tool call"


def mint_back_confirmed(names: list[str]) -> str:
tail = "these exact values" if len(names) > 1 else "this exact value"
return (
f"[MCP INSTRUCTIONS]: {' and '.join(names)} confirmed. "
f"Keep sending {tail} on every call."
)
return f"session_id: {session_id}"
82 changes: 38 additions & 44 deletions src/agentcat/modules/handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
AGENTCAT_TAG_NESTED,
AGENTCAT_TAG_PROTOCOL_VERSION,
AGENTCAT_TAG_SESSION_SOURCE,
MCP_INSTRUCTIONS_KEY,
MINT_BACK_CLOSER,
MINT_BACK_HEADER_INVALID,
MINT_BACK_HEADER_SESSION,
MINT_BACK_INVALID_LINE,
MCP_SESSION_KEY,
MINT_BACK_HEADER_ISSUED,
MINT_BACK_HEADER_UNRECOGNIZED,
MINT_BACK_ISSUED_BODY,
MINT_BACK_UNRECOGNIZED_BODY,
SESSION_ID_PARAM,
SESSION_ID_PREFIX,
mint_back_confirmed,
SESSION_START_SENTINEL,
mint_back_session_line,
)
from agentcat.modules.hooks import run_hook
Expand Down Expand Up @@ -184,7 +184,13 @@ async def resolve_handles(
)

supplied = extract_handle(arguments, SESSION_ID_PARAM)
if supplied:
# The `start` sentinel is checked BEFORE shape validation and resolves
# exactly like an absent value: it is the explicit spelling of "begin a
# new task" the required parameter's copy asks for. Case-insensitive
# (extract_handle already trimmed), and only ever read here — a foreign
# customer-owned value took the branch above, so `start` in a parameter
# the customer declared is never interpreted as a sentinel.
if supplied and supplied.lower() != SESSION_START_SENTINEL:
if is_valid_session_id(supplied):
return HandleResolution(
supplied,
Expand All @@ -210,39 +216,24 @@ async def resolve_handles(
)


def _echoes_session_id(res: HandleResolution) -> bool:
"""Whether the agent has an AgentCat `session_id` value to echo back.

Three ways to have none: hook mode and the no-parameter cases collapsed
into `prompts_session_id`, plus `invalid` — where the parameter is ours
but there is no value to confirm. That branch corrects the agent rather
than issuing a replacement, so naming a `session_id` would be a lie.
"""
return (
res.prompts_session_id and not res.hook_mode and res.session_source != "invalid"
)


def build_mint_back_text(res: HandleResolution) -> str | None:
if res.hook_mode or not res.prompts_session_id:
return None
if res.session_source == "minted":
return "\n".join(
[
MINT_BACK_HEADER_SESSION,
MINT_BACK_HEADER_ISSUED,
mint_back_session_line(res.session_id),
MINT_BACK_CLOSER,
MINT_BACK_ISSUED_BODY,
]
)
if res.session_source == "invalid":
# No replacement is handed out. An agent that sent something was
# usually already issued a good ID, and giving it a second one splits a
# session that was never split. The closing sentence of
# MINT_BACK_INVALID_LINE is the way out for the agent that was never
# issued one: omit the parameter and take the `minted` branch.
return "\n".join(
[MINT_BACK_HEADER_INVALID, MINT_BACK_INVALID_LINE, MINT_BACK_CLOSER]
)
# MINT_BACK_UNRECOGNIZED_BODY is the way out for the agent that was
# never issued one: send `start` and take the `minted` branch.
return "\n".join([MINT_BACK_HEADER_UNRECOGNIZED, MINT_BACK_UNRECOGNIZED_BODY])
return None


Expand All @@ -251,41 +242,44 @@ def build_structured_mint_back(res: HandleResolution) -> dict[str, Any] | None:

Unlike `build_mint_back_text` (mint announcements only), this is present
on EVERY response, so an agent can re-read its own handles mid-session.
Handles the agent cannot echo are never named.
Handles the agent cannot echo are never named: `status` and `session_id`
appear only in prompted mode — hook mode and a customer-owned session
parameter mirror `agent_id` alone.

Suppression is per-HANDLE, not per-response: a `session_id` collision skips
only `session_id`. `agent_id` is a separate injection and still landed in
that tool's schema, so it is still ours to confirm. Dropping the whole
mirror would withhold a handle AgentCat issued purely because a
neighbouring one belongs to the customer.
"""
echoes = _echoes_session_id(res)
names: list[str] = []
if echoes:
names.append(SESSION_ID_PARAM)
if res.agent_id:
names.append(AGENT_ID_PARAM)
text = build_mint_back_text(res)
# `not names` alone would drop the `invalid` correction whenever no
# agent_id is in play — the one branch that has something to say and
# nothing to echo.
if not names and not text:
return None
prompted = res.prompts_session_id and not res.hook_mode
mint: dict[str, Any] = {}
if echoes:
if prompted and res.session_source in ("minted", "supplied"):
# `invalid` names no session_id: no replacement is issued, and the
# recovery path is described under `status`.
mint[SESSION_ID_PARAM] = res.session_id
if res.agent_id:
mint[AGENT_ID_PARAM] = res.agent_id
mint["instructions"] = text or mint_back_confirmed(names)
if prompted:
if res.session_source == "minted":
mint["status"] = "issued"
elif res.session_source == "supplied":
mint["status"] = "active"
elif res.session_source == "invalid":
mint["status"] = "unrecognized"
if not mint:
return None
return mint


def mirror_into_structured_content(
sc: Any, mint: dict[str, Any]
) -> dict[str, Any] | None:
if not isinstance(sc, dict) or MCP_INSTRUCTIONS_KEY in sc:
if not isinstance(sc, dict) or MCP_SESSION_KEY in sc:
return None
return {**sc, MCP_INSTRUCTIONS_KEY: mint}
# Mirror first: an ID at the tail of a long payload is what clients
# truncate away.
return {MCP_SESSION_KEY: mint, **sc}


def _clamp_tag_value(value: str) -> str:
Expand Down
Loading
Loading