Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ tools:
per_page: {value: 25}
describe:
ListSearchResults: Search tickets. The kind is fixed to tickets.
omit:
ListSearchResults: [results.fields] # every search result, one by one
ShowTicket:
- ticket.fields # the custom fields again, under another name
- path: ticket.custom_fields # just the ones that hold no value
where_empty: value
- openapi:
spec: ./specs/helpdesk.yaml
connection: helpdesk
Expand Down Expand Up @@ -279,6 +285,21 @@ tools:
that parameter's, as with `params`. Counting up what goes out fails safe
as the description grows: a new field stays unexposed until someone
declares it.
- **`omit` keeps results down to what the agent reads.** A published record
can carry far more than an agent needs — a helpdesk ticket may list its
custom fields twice, under two names — and every part of a result is
something the model may misread. A dotted path drops that part
of the result, stepping through every array on the way, so
`results.fields` drops it from each search result. With `where_empty`, the
path names an array that stays, minus the elements whose key holds
nothing: null, an empty string, an empty array or object, or no such key
at all. `false` and `0` are values and stay, and so does an element that
is no object. A result that does not carry the path is left as it came.
`validate` refuses a path the success responses rule out — one through a
value they declare as a scalar, or past an object that allows no other
properties — but not one they merely leave undeclared: published response
schemas routinely leave out fields the service sends. What is left passes
the policies' redaction like every other result.
- **`describe` replaces the vendor's text.** Vendor descriptions are written
for developers sitting next to the docs and often cite links a model
cannot follow; `does_not` is appended to every tool, as with `mcp:`.
Expand Down
165 changes: 164 additions & 1 deletion src/gete/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ def declaration_problems(block: Mapping[str, Any], spec: Any) -> list[str]:
)
continue
found.extend(_operation_problems(operation, effect))
for key in ("params", "describe", "only"):
for key in ("params", "describe", "only", "omit"):
for name in block.get(key, {}):
if name not in selected:
found.append(f"{key}: {name!r} is not one of this block's operations")
Expand All @@ -388,6 +388,10 @@ def declaration_problems(block: Mapping[str, Any], spec: Any) -> list[str]:
spec, operation, exposed, block.get("params", {}).get(name, {})
)
)
for name, entries in block.get("omit", {}).items():
operation = operations.get(name)
if operation is not None and name in selected:
found.extend(_omit_problems(spec, operation, entries))
return found


Expand Down Expand Up @@ -683,6 +687,165 @@ def _only_problems(
return found


def _omit_problems(
spec: Any, operation: Operation, entries: Iterable[Any]
) -> list[str]:
"""The omit paths the success responses rule out.

Only what is certain is reported. A published response schema routinely
leaves out fields the service sends, so a name it does not declare is no
evidence of anything; a path through a value it declares as a scalar is.
With several success responses, a path is ruled out only by all of them.
"""
schemas = _response_schemas(spec, operation)
if not schemas:
return []
found: list[str] = []
for entry in entries:
path = str(entry if isinstance(entry, str) else entry["path"])
segments = tuple(path.split("."))
walks = [_walk_result(spec, schema, segments) for schema in schemas]
if all(problem for problem, _ in walks):
found.append(
f"omit.{operation.id}: {path!r} cannot be in the result; {walks[0][0]}"
)
continue
if isinstance(entry, str):
continue
types = [
_declared_type(leaf) if leaf is not None else None for _, leaf in walks
]
if all(declared not in (None, "array") for declared in types):
found.append(
f"omit.{operation.id}: where_empty filters the elements of an "
f"array, and {path!r} is {_a(str(types[0]))}"
)
continue
key = str(entry["where_empty"])
walks = [_walk_result(spec, schema, (*segments, key)) for schema in schemas]
if all(problem for problem, _ in walks):
found.append(
f"omit.{operation.id}: where_empty {key!r} cannot be in the "
f"elements of {path!r}; {walks[0][0]}"
)
return found


def _response_schemas(spec: Any, operation: Operation) -> list[Mapping[str, Any]]:
"""The JSON schemas the operation answers with when it succeeds."""
responses = operation.document.get("responses")
if not isinstance(responses, Mapping):
return []
found: list[Mapping[str, Any]] = []
for code, response in responses.items():
if not re.fullmatch(r"2(\d\d|XX)", str(code)):
continue
resolved = _resolve(spec, response)
content = resolved.get("content") if isinstance(resolved, Mapping) else None
if not isinstance(content, Mapping):
continue
for media, entry in content.items():
if media == "application/json" or str(media).endswith("+json"):
schema = (
_resolve(spec, entry.get("schema"))
if isinstance(entry, Mapping)
else None
)
# A JSON answer with no schema may hold anything at all.
found.append(schema if isinstance(schema, Mapping) else {})
return found


# JSON Schema types that hold no properties.
SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean"})


def _declared_type(schema: Mapping[str, Any]) -> str | None:
"""The one type a schema declares, or None when it does not pin one.

OpenAPI 3.1 writes a nullable value as a list such as ["string", "null"];
a null holds no properties either, so the other type is what a path
meets whenever there is a value. A list of several other types is not
chosen between, as oneOf is not.
"""
declared = schema.get("type")
if isinstance(declared, str):
return declared
if not isinstance(declared, list):
return None
kinds = [kind for kind in declared if kind != "null"]
return kinds[0] if len(kinds) == 1 and isinstance(kinds[0], str) else None


def _a(word: str) -> str:
"""The word with its indefinite article."""
return f"{'an' if word[:1] in ('a', 'e', 'i', 'o', 'u') else 'a'} {word}"


def _walk_result(
spec: Any, schema: Mapping[str, Any], segments: tuple[str, ...]
) -> tuple[str | None, Mapping[str, Any] | None]:
"""Follow a path into a response schema: why the schema rules it out, or
the schema declared at its end. Neither, once the walk leaves what the
schema declares - the path may still be there.

Arrays on the way are stepped through to their items, as the runtime
steps through every element; the one at the end is the leaf itself.
oneOf and anyOf are not chosen between, so a path below one is never
ruled out.
"""
node: Any = schema
trail: list[str] = []
for segment in segments:
holder, stepped = _object_schema(spec, node)
if holder is None:
return None, None
where = repr(".".join(trail)) if trail else "the result"
declared = _declared_type(holder)
if declared in SCALAR_TYPES:
if stepped:
return (
f"{where} holds {declared} elements, which hold no properties",
None,
)
return f"{where} is {_a(declared)}, which holds no properties", None
properties = holder.get("properties")
properties = properties if isinstance(properties, Mapping) else {}
if segment in properties:
node = properties[segment]
trail.append(segment)
continue
if holder.get("additionalProperties") is False:
known = ", ".join(sorted(map(str, properties)))
return (
f"{segment!r} is not a property of {where}, which allows no "
f"others (properties: {known})",
None,
)
return None, None
leaf = _resolve(spec, node)
return None, (_fold_allof(spec, leaf) if isinstance(leaf, Mapping) else None)


def _object_schema(spec: Any, node: Any) -> tuple[Mapping[str, Any] | None, bool]:
"""The schema at node with references followed, allOf folded, and any
arrays stepped through to what their elements are; and whether an array
was stepped through."""
stepped = False
# A YAML alias can make an array its own items; the walk stops there.
seen: set[int] = set()
while True:
node = _resolve(spec, node)
if not isinstance(node, Mapping) or id(node) in seen:
return None, stepped
seen.add(id(node))
node = _fold_allof(spec, node)
if _declared_type(node) != "array":
return node, stepped
node = node.get("items")
stepped = True


def allow_tree(entries: Iterable[str], literal: Iterable[str] = ()) -> dict[str, Any]:
"""An ``only`` list as a tree of what the model may write.

Expand Down
74 changes: 73 additions & 1 deletion src/gete/runtime/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,64 @@ class NestedFix:
suffix: str = ""


@dataclass(frozen=True)
class Omission:
"""One part of a result the model is never shown, by a dotted path.

With where_empty, the path names an array and only the elements whose
key holds nothing are dropped; the rest of the array stays.
"""

path: tuple[str, ...]
where_empty: str | None = None

@classmethod
def from_declaration(cls, entry: str | Mapping[str, Any]) -> "Omission":
if isinstance(entry, str):
return cls(path=tuple(entry.split(".")))
return cls(
path=tuple(str(entry["path"]).split(".")),
where_empty=str(entry["where_empty"]),
)

def apply(self, value: Any) -> Any:
return _omitted(value, self.path, self.where_empty)


def _is_empty(value: Any) -> bool:
"""null, "", [] and {}. false and 0 are answers, not absences."""
return value is None or (isinstance(value, str | list | dict) and not value)


def _omitted(value: Any, path: tuple[str, ...], where_empty: str | None) -> Any:
"""The value without what path names; a copy, the answer itself untouched.

An array anywhere on the way is stepped through, each element walked on
its own: results come back as lists of records far more often than as
one record.
"""
if isinstance(value, list):
return [_omitted(item, path, where_empty) for item in value]
if not isinstance(value, dict) or path[0] not in value:
return value
head, rest = path[0], path[1:]
if rest:
return {**value, head: _omitted(value[head], rest, where_empty)}
if where_empty is None:
return {key: item for key, item in value.items() if key != head}
elements = value[head]
if not isinstance(elements, list):
return value
return {
**value,
head: [
element
for element in elements
if not (isinstance(element, dict) and _is_empty(element.get(where_empty)))
],
}


def _filtered(value: Any, tree: Mapping[str, Any] | None) -> Any:
"""What of the model's value may pass: the tree's subtrees of it.

Expand Down Expand Up @@ -140,6 +198,7 @@ def __init__(
connection: Connection,
arguments: Iterable[Argument],
nested_fixes: Iterable[NestedFix] = (),
omissions: Iterable[Omission] = (),
declaration: Any,
require_confirmation: bool,
base_url: str | None = None,
Expand All @@ -150,6 +209,7 @@ def __init__(
self._connection = connection
self._arguments = tuple(arguments)
self._nested_fixes = tuple(nested_fixes)
self._omissions = tuple(omissions)
self._declaration = declaration
self._require_confirmation = require_confirmation
self._base_url = base_url
Expand Down Expand Up @@ -202,8 +262,17 @@ async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any:
if "{" in path:
raise GeteError(f"{self.name}: a path parameter was not given")
url = root.rstrip("/") + path
answer = await self._send(
url, query, body, getattr(tool_context, "state", None)
)
for omission in self._omissions:
answer = omission.apply(answer)
return answer

async def _send(
self, url: str, query: dict[str, Any], body: dict[str, Any], state: Any
) -> Any:
client = shared_client(self._connection.id)
state = getattr(tool_context, "state", None)
if self._method == "get":
return await client.get_json(url, params=query or None, state=state)
if self._method == "delete":
Expand Down Expand Up @@ -309,6 +378,7 @@ def openapi_toolset(
name=str(name),
fixes=spec.get("params", {}).get(name, {}),
only=spec.get("only", {}).get(name),
omit=spec.get("omit", {}).get(name, ()),
description=describe.get(name),
does_not=does_not,
connection=connection,
Expand Down Expand Up @@ -362,6 +432,7 @@ def _tool(
name: str,
fixes: Mapping[str, Mapping[str, Any]],
only: Iterable[str] | None = None,
omit: Iterable[Any] = (),
description: str | None,
does_not: str | None,
connection: Connection,
Expand Down Expand Up @@ -445,6 +516,7 @@ def _tool(
connection=connection,
arguments=arguments,
nested_fixes=nested,
omissions=[Omission.from_declaration(entry) for entry in omit],
declaration=declaration,
require_confirmation=require_confirmation,
base_url=base_url,
Expand Down
41 changes: 41 additions & 0 deletions src/gete/schema/agent.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,18 @@
}
}
},
"omit": {
"description": "Parts of the result the model is never shown, by operationId. A dotted path steps through every array on the way. A published record can carry far more than an agent reads, and every part of it is something the model may misread or quote.",
"type": "object",
"additionalProperties": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/openapi_omission"
}
}
},
"describe": {
"description": "Replaces an operation's description. The vendor's text is written for developers next to the docs, not for a model; it may cite links and syntax the model cannot follow.",
"type": "object",
Expand Down Expand Up @@ -385,6 +397,35 @@
"type": "string",
"pattern": "^https://[^/?#\\s]+(?:[/?#]\\S*)?$"
},
"dotted_path": {
"type": "string",
"pattern": "^[^.]+(\\.[^.]+)*$"
},
"openapi_omission": {
"description": "A dotted path dropped from the result, or an array at a path of which only the elements whose where_empty key holds nothing (null, an empty string, array, or object, or no key at all) are dropped. false and 0 are values.",
"oneOf": [
{
"$ref": "#/$defs/dotted_path"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"where_empty"
],
"properties": {
"path": {
"$ref": "#/$defs/dotted_path"
},
"where_empty": {
"type": "string",
"pattern": "^[^.]+$"
}
}
}
]
},
"openapi_param": {
"description": "One parameter's constraint: a fixed value the model never sees, or text put around what the model writes. A fixed parameter is taken out of the declaration shown to the model - its value is declared, so there is nothing left for the model to say, and prefix or suffix would have nothing to wrap. A null value is refused: the client drops absent values, so a fixed null would silently send nothing at all.",
"oneOf": [
Expand Down
Loading
Loading