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 cecli/helpers/hashpos/transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ def apply_contextual_marker(
range_end: str,
num_lines: int,
) -> tuple[list[int], list[int]]:
"""Expand range using @C{{num}}, @P{{num}}, or @N{{num}} contextual markers.
"""Expand range using @C<num>, @P<num>, or @N<num> contextual markers.

Requires exactly one start match. Raises ValueError when the number
of start matches is not 1 (the caller is expected to format the error
Expand Down
6 changes: 4 additions & 2 deletions cecli/helpers/model_config/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,9 @@ def _closest_provider_match(sources, provider, route):

Raw sources are enumerated via a lightweight top-level key scan (a string
pass, never a full parse) and only ``provider/``-prefixed keys are parsed
for scoring, so the fallback stays memory friendly.
for scoring, so the fallback stays memory friendly. A candidate with no
shared leading characters is not a match: returning it would let an
unrelated route inherit an arbitrary sibling record's wire mode.
"""
if not provider:
return None
Expand Down Expand Up @@ -376,7 +378,7 @@ def _closest_provider_match(sources, provider, route):
best_score = score
best = record

return best
return best if best_score > 0 else None


def _prefix_score(left, right):
Expand Down
4 changes: 4 additions & 0 deletions cecli/helpers/model_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,10 @@ def _fetch_provider_models(self, provider: str) -> Optional[Dict]:
)
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError:
# Unreachable endpoint (e.g. no local Ollama server at
# localhost:11434): stay silent and contribute no models.
return None
except Exception as ex:
print(f"Failed to fetch {provider} model list: {ex}")
return None
Expand Down
17 changes: 17 additions & 0 deletions cecli/helpers/orchestration/agent_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,27 @@ def get_tool(self, tool_name: str) -> ToolProxy:
self._coder,
mcp_server=server,
mcp_tool_name=schema_name,
tool_schema=tool_schema,
)

raise ValueError(f"Unknown tool: '{tool_name}'")

def get_tool_schema(self, tool_name: str) -> dict[str, Any] | None:
"""Return a copy of the full function-calling schema for a tool.

Returns ``None`` when the tool has no schema. Unknown or disallowed
tools raise the same errors as ``get_tool``.
"""
return self.get_tool(tool_name).get_schema()

def get_tool_signature(self, tool_name: str) -> str | None:
"""Return a readable Python-style call signature for a tool.

Optional parameters without a declared default use ``...`` to show
that they may be omitted without implying a value such as ``None``.
"""
return self.get_tool(tool_name).get_signature()

def _find_mcp_server(self, server_name: str, server_prefix: str) -> Any:
if not hasattr(self._coder, "mcp_manager") or not self._coder.mcp_manager:
return None
Expand Down
6 changes: 4 additions & 2 deletions cecli/helpers/orchestration/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
context = """<context name="orchestration" from="agent">
The `Orchestrate` tool runs Python in a sandbox where you can script other tools programmatically.
Use it for batch, loop-heavy, and repeat-able workflows.
Variables and helpers persist across calls; `state` persists across all Orchestrate calls in the session.
The sandbox is stateless between calls — variables don't persist. To carry values across calls, use `state` (per-agent) or `shared_state` (cross-agent).
You may need to explore the below primitives to understand how to use the sandbox effectively.

### Primitives
Expand All @@ -556,11 +556,13 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
|-----------|--------------|
| `Agent.allowed_methods()` / `Agent.allowed_tools()` | List helper methods and available tools |
| `Agent.get_tool(name)` | Get a tool proxy (case-insensitive; `Local--` / `{{Server Name}}--` prefixes ok) |
| `Agent.get_tool_schema(name)` | Return a copy of the full function-calling JSON schema |
| `Agent.get_tool_signature(name)` | Return a readable Python-style signature; optional params without defaults appear as `= ...` |
| `await tool.call(**params)` | Run a tool; returns `{"result": [...], "errors": [...], "details": [...]}`, items with shape `{"content", "_"}` |
| `Agent.peek(result)` / `Agent.get_value(result, path, default?)` | Inspect / extract values from tool results. path is dot-separated string |
| `Agent.resolve_regions(path, specs)` / `Agent.edit_region(path, edits)` | Resolve text boundaries once, then apply edits |
| `gather(**tasks)` | Run tasks concurrently; results expose `.key` and `["key"]` |
| `state` / `shared_state` | Persistent dicts; `state.get(k)` falls back to `shared_state` |
| `state` / `shared_state` | Persistent dicts that survive between Orchestrate calls; `state.get(k)` falls back to `shared_state`. Use these for anything you need to reuse in a later call |
| `print(...)` / `reset(local_vars=True, state=False)` | Emit output / clear namespaces |
| `typeof(x)`, `isinstance(x, t)`, `hasattr(x, n)`, `repr(x)`, `vars(obj)` | Type inspection and debugging |

Expand Down
21 changes: 11 additions & 10 deletions cecli/helpers/orchestration/safe_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import pathlib
import re as _re_mod
import traceback as _tb_mod
from collections.abc import Mapping
from typing import Any

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -139,12 +140,12 @@ async def _safe_sleep(seconds: float) -> None:
await asyncio.sleep(seconds)


class GatherResult:
"""Result container for named ``gather()`` calls.
class GatherResult(Mapping[str, Any]):
"""Read-only mapping of named ``gather()`` task results.

Supports both attribute access (``results.my_task``) and
key access (``results["my_task"]``), plus ``len()`` and
iteration for unpacking.
Supports standard mapping access (``results["task"]``, iteration over
keys, and ``keys()``, ``values()``, and ``items()``) plus attribute access
as a convenience. Use key access when task names may collide with methods.
"""

def __init__(self, results: dict[str, Any]) -> None:
Expand Down Expand Up @@ -192,7 +193,7 @@ def __len__(self) -> int:

def __iter__(self):
results = object.__getattribute__(self, "_results")
return iter(results.items())
return iter(results)

def keys(self) -> Any:
results = object.__getattribute__(self, "_results")
Expand Down Expand Up @@ -225,12 +226,12 @@ async def _safe_gather(*args: Any, **named_awaitables: Any):
Safely execute multiple awaitables concurrently.

All awaitables must be passed as keyword arguments. Results are returned
as a ``GatherResult`` with attribute and key access:
as a ``GatherResult`` mapping with attribute access as a convenience:

results = await gather(read_a=task_a, grep_b=task_b)
print(results.read_a) # attribute access
print(results["grep_b"]) # key access
len(results) # number of results
print(results["read_a"]) # canonical mapping access
print(results.grep_b) # attribute convenience
list(results) # task names

Forces ``return_exceptions=True`` so that failures in one task
do not crash the entire batch. Exceptions are converted to
Expand Down
87 changes: 83 additions & 4 deletions cecli/helpers/orchestration/tool_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import asyncio
import json
from copy import deepcopy
from typing import Any


Expand Down Expand Up @@ -63,6 +64,7 @@ def __init__(
tool_module: Any = None,
mcp_server: Any = None,
mcp_tool_name: str = "",
tool_schema: dict[str, Any] | None = None,
) -> None:
# Respect the per-coder tool includelist/excludelist filters
incl = getattr(coder, "registered_tools", {}).get("included", set())
Expand All @@ -78,6 +80,16 @@ def __init__(
self._tool_module = tool_module
self._mcp_server = mcp_server
self._mcp_tool_name = mcp_tool_name
self._tool_schema = (
tool_schema if tool_schema is not None else getattr(tool_module, "SCHEMA", None)
)

def get_schema(self) -> dict[str, Any] | None:
"""Return a deep copy of this tool's JSON schema, if available."""
if not isinstance(self._tool_schema, dict):
return None

return deepcopy(self._tool_schema)

async def __call__(self, *args: Any, **kwargs: Any):
"""Make the proxy directly callable.
Expand Down Expand Up @@ -131,12 +143,79 @@ async def __call__(self, *args: Any, **kwargs: Any):

return await self.call(**kwargs)

def get_signature(self) -> str | None:
"""Return a readable Python-style signature based on the JSON schema.

Type annotations are best-effort summaries; use the schema for exact
constraints. Optional parameters without defaults display as ``...``.
"""
schema = self._tool_schema
if not isinstance(schema, dict):
return None

function = schema.get("function", {})
if not isinstance(function, dict):
return None

parameters = function.get("parameters", {})
if not isinstance(parameters, dict):
return None

properties = parameters.get("properties", {})
if not isinstance(properties, dict):
return None

required = parameters.get("required", [])
required = set(required) if isinstance(required, list) else set()
signature_parameters = []
for name, property_schema in properties.items():
annotation = self._python_type_name(property_schema)
parameter = f"{name}: {annotation}"
if name not in required:
if isinstance(property_schema, dict) and "default" in property_schema:
parameter += f" = {property_schema['default']!r}"
else:
parameter += " = ..."
signature_parameters.append(parameter)

function_name = function.get("name") or self._tool_name
if signature_parameters:
return f"{function_name}(*, {', '.join(signature_parameters)})"
return f"{function_name}()"

@staticmethod
def _python_type_name(schema: Any) -> str:
"""Render common JSON Schema types as readable Python type names."""
if not isinstance(schema, dict):
return "Any"

schema_type = schema.get("type")
if isinstance(schema_type, list):
type_names = [ToolProxy._python_type_name({"type": item}) for item in schema_type]
return " | ".join(type_names)

type_names = {
"array": "list",
"boolean": "bool",
"integer": "int",
"number": "float",
"null": "None",
"object": "dict",
"string": "str",
}
if schema_type in type_names:
return type_names[schema_type]

alternatives = schema.get("anyOf") or schema.get("oneOf")
if isinstance(alternatives, list):
return " | ".join(ToolProxy._python_type_name(item) for item in alternatives)

return "Any"

def _get_param_names(self) -> list:
"""Extract ordered parameter names from the tool's JSON Schema."""
if self._tool_module is None:
return []
"""Extract parameter names in schema order for positional calls."""
try:
props = self._tool_module.SCHEMA["function"]["parameters"]["properties"]
props = self._tool_schema["function"]["parameters"]["properties"]
return list(props.keys())
except (KeyError, TypeError, AttributeError):
return []
Expand Down
18 changes: 17 additions & 1 deletion cecli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ async def _enter_client_session(self, read, write):
ClientSession(
read,
write,
read_timeout_seconds=timedelta(seconds=self._request_timeout_seconds()),
read_timeout_seconds=_client_session_read_timeout(self._request_timeout_seconds()),
)
)
await session.initialize()
Expand Down Expand Up @@ -732,3 +732,19 @@ def _unpack_transport(transport):
"""
read, write = transport[0], transport[1]
return read, write


def _client_session_read_timeout(seconds: float) -> timedelta | float:
"""Return a read timeout in the form the installed mcp SDK expects.

mcp SDK 1.x types ``ClientSession.read_timeout_seconds`` as a
``timedelta``; SDK 2.x types it as a float number of seconds and adds it to
other floats internally, so a ``timedelta`` raises ``TypeError:
unsupported operand type(s) for +: 'float' and 'datetime.timedelta'`` at
connect time. Return whichever type the installed SDK wants, so both majors
keep the same per-request timeout instead of failing to connect.
"""
if _get_mcp_major_version() >= 2:
return seconds

return timedelta(seconds=seconds)
32 changes: 32 additions & 0 deletions cecli/resources/model-metadata.ext.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,38 @@
"supports_parallel_function_calling": true,
"supports_response_schema": true
},
"github_copilot/gpt-6-luna": {
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"source": "https://docs.github.com/en/copilot/reference/ai-models/supported-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_vision": true
},
"github_copilot/gpt-6-sol": {
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"source": "https://docs.github.com/en/copilot/reference/ai-models/supported-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_vision": true
},
"github_copilot/grok-4.6": {
"litellm_provider": "github_copilot",
"max_input_tokens": 128000,
Expand Down
Loading
Loading