Skip to content

feat: add lyzr-tools plugin bridging Lyzr-authorized tools into GitAgent - #78

Open
akshatkumar2808 wants to merge 1 commit into
open-gitagent:mainfrom
akshatkumar2808:feat/lyzr-tools-bridge
Open

feat: add lyzr-tools plugin bridging Lyzr-authorized tools into GitAgent#78
akshatkumar2808 wants to merge 1 commit into
open-gitagent:mainfrom
akshatkumar2808:feat/lyzr-tools-bridge

Conversation

@akshatkumar2808

Copy link
Copy Markdown

Summary

  • Adds a lyzr-tools plugin that discovers Lyzr-authorized tools (Gmail, Slack, MCP servers) and proxies execution through Lyzr instead of requiring separate local credentials
  • Registers discovered tools as lyzr_-prefixed GitAgent tools; unauthorized tools return a structured authorization_required result instead of prompting for local credentials
  • Redacts secrets from tool result details; adds prompt guidance preferring lyzr_* tools over local duplicate skills
  • Enabled by default in agent.yaml; no-ops with a warning if LYZR_API_KEY isn't set (no network call without a key)

See docs/lyzr-tool-auth-rca.md for the RCA/design and docs/lyzr-tool-bridge-test-cases.md for the acceptance criteria this targets.

Test plan

  • npm run build && npm test — 59/59 passing (32 new in test/lyzr-tools.test.ts, no real network calls)
  • Verified end-to-end through the real plugin loader (not just mocks): no-API-key no-op path, and full discovery→register→execute cycle against a local HTTP stub

GitAgent's LYZR_API_KEY was only wired into the model path, so users with
Gmail/Slack/etc. already authorized in Lyzr still had to configure separate
local credentials (e.g. local Gmail SMTP app passwords) for tool execution.

Adds a lyzr-tools plugin that:
- Discovers authorized provider actions (GET /v3/providers/tools/actions/*)
  and MCP server tools (/v3/tools/mcp/servers*), cross-referenced against
  connected-account status (/v3/tools/credentials/connected_accounts).
- Registers each as a lyzr_-prefixed gitagent tool, avoiding collisions
  with local skills by construction.
- Proxies execution through /v3/inference/tools/execute or
  /v3/tools/mcp/tools/execute, normalizing results into success /
  authorization_required / error, with secret redaction on all details.
- Adds prompt guidance preferring lyzr_ tools over local duplicates
  (e.g. the bundled gmail-email skill) when both exist.

Enabled by default in agent.yaml; no-ops with a single warning if
LYZR_API_KEY isn't set, so it never makes a network call without a key.

See docs/lyzr-tool-auth-rca.md for the RCA and design this implements, and
docs/lyzr-tool-bridge-test-cases.md for the acceptance criteria covered by
test/lyzr-tools.test.ts (32 tests, no real network calls).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@shreyas-lyzr shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four issues worth addressing before merge — one is a confirmed functional break on the default config, one is a silent misconfiguration that yields a broken user experience with no log output to help diagnose it, one is a schema normalization gap that silently drops valid tool parameters, and one is a key-based redaction limitation that can let raw credential strings through to model context. Inline comments below.

): Promise<void> {
for (const providerId of config.providers) {
stats.providersQueried++;
const res = await client.listProviderActions(providerId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool_source is never passed to listProviderActions. The client signature accepts opts?: { toolSource?: string; appId?: string } but this call site passes no second argument, so tool_source is always omitted from the query string.

The Swagger spec for GET /v3/providers/tools/actions/{provider_identifier} treats tool_source as a required disambiguator when provider_identifier is a plain provider name (e.g. "gmail", "slack") rather than a provider_uuid/ObjectId. Without it the backend returns 400, the error is pushed to stats.errors and logged as a warn, but the plugin loads silently with zero tools registered. The default providers: "gmail,slack" in plugin.yaml hits this path exactly.

Fix: thread toolSource through ResolvedConfig (it's already an optional field in plugin.yaml's config block) and pass it here:

Suggested change
const res = await client.listProviderActions(providerId);
const res = await client.listProviderActions(providerId, { toolSource: config.toolSource });

Or, if tool_source should be per-provider rather than global, make config.providers a { id: string; toolSource?: string }[] array.

logger: Logger,
): Promise<Map<string, ConnectedAccount>> {
const map = new Map<string, ConnectedAccount>();
if (!config.userId) return map;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When config.userId is absent, fetchConnectedAccounts returns an empty map with no log output. discoverProviderActions then finds no connected account for any provider, marks every tool authorized: false, and the plugin loads with a full tool list where every call returns authorization_required.

With the example agent.yaml config (which only sets api_key), a user who omits user_id sees every tool fail immediately with no indication of why. A single warn log before the early return would make this diagnosable in under a minute:

Suggested change
if (!config.userId) return map;
if (!config.userId) {
logger.warn("lyzr-tools: user_id is not configured; all provider tools will be marked unauthorized. Set user_id in agent.yaml or LYZR_USER_ID.");
return map;
}

return [];
}

function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeInputSchema only passes through schemas that are plain objects with a properties key. If Lyzr returns an action schema in OpenAI function-calling style — where parameters is an array of { name, type, description } objects, which is a common ACI format — that array fails the "properties" in schema check and is silently replaced with { properties: {} }.

The tool registers with no known parameters; the model has to guess argument names, which causes silent failures for any required fields.

At minimum, handle the array case by converting it to a JSON Schema object shape:

Suggested change
function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } {
function normalizeInputSchema(schema: unknown): { properties: Record<string, any>; required?: string[] } {
if (Array.isArray(schema)) {
// OpenAI/ACI-style: [{name, type, description, required?}]
const properties: Record<string, any> = {};
const required: string[] = [];
for (const p of schema as Array<Record<string, any>>) {
if (!p.name) continue;
properties[p.name] = { type: p.type ?? "string", description: p.description ?? "" };
if (p.required) required.push(p.name);
}
return required.length ? { properties, required } : { properties };
}
if (schema && typeof schema === "object" && "properties" in (schema as Record<string, unknown>)) {
return schema as { properties: Record<string, any>; required?: string[] };
}
return { properties: {} };
}

// Only values whose *key* looks sensitive are masked. We deliberately do not
// try to pattern-match "secret-looking" strings by shape, because that would
// also mangle legitimate tool output (email bodies, message text, etc.) —
// see docs/lyzr-tool-bridge-test-cases.md TC-D03.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment accurately describes the design, but it understates the risk for users. Key-based redaction works well when sensitive data lives in a recognisably-named field. However, if Lyzr's execution API returns a raw OAuth token or credential blob as a plain string — e.g. data.result = "ya29.A0ARrd…" — the field key is result, which does not match SENSITIVE_KEY_RE. The value passes through unredacted, appears as the tool's text output, and lands directly in model context.

This isn't hypothetical: some OAuth provider responses embed access tokens at the top level of their result body.

Worth adding to the known-limitations note in README.md that Lyzr's execution backend must not return raw credentials in the result field, and optionally adding a shape-based heuristic here (e.g. flag strings that are long, high-entropy base64 and don't match human-readable output) or a specific blocklist check on the result key value regardless of key name.

Comment thread agent.yaml
@@ -11,4 +11,12 @@ tools:
- write
- memory

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file is missing a trailing newline (the diff shows \ No newline at end of file on the last line). Minor, but it breaks POSIX compliance and produces noisy diffs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants