Skip to content

Feat(core): apply FQN naming to published tools - #1833

Closed
tuannx wants to merge 2 commits into
embabel:mainfrom
tuannx:fix/agent-platform-name-collision
Closed

tuannx wants to merge 2 commits into
embabel:mainfrom
tuannx:fix/agent-platform-name-collision

Conversation

@tuannx

@tuannx tuannx commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Opt-in naming for goal tools exported through MCP, so goals from different agents get distinct names.

embabel.agent.platform.tools.naming-strategy: fully-qualified   # default: legacy-name-only

Before (default LEGACY_NAME_ONLY) vs after (FULLY_QUALIFIED), application name myapp:

Goal Before After
com.acme.AgentA.search myapp_AgentA_search AgentA-search
com.acme.AgentB.search myapp_AgentB_search AgentB-search
com.acme.AgentA.search accepting UserInput or Order myapp_AgentA_search twice AgentA-search-UserInput, AgentA-search-Order
export.name = "my.export" my.export my.export (unchanged)

Names over 64 characters fail at startup instead of being truncated. Default behaviour is unchanged.

Scope

  • ToolNamingStrategy (new, api.tool) + embabel.agent.platform.tools.naming-strategy property
  • PerGoalToolFactory applies it; sync/async MCP publishers inject it from AgentPlatformProperties
  • No changes to LLM tool calls, supervisor, or spi/support

Not in this PR

Duplicate-name detection across the full tool list (incl. _confirm) — #1834.

Related: #1990.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 27, 2026 05:11

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@igordayen

Copy link
Copy Markdown
Contributor

@tuannx - thanks for reporting.
Per the process, could you please create an issue for this PR?
Also, could you please help interpret the write-up on the issue, preferably with simpler prose? A bit harder to understand the root cause hidden by the write-up potentially generated by AI.

Also - appears something got broken due to migration, or its existing issue; could you please try to dig into the history of the problem?
Thank you for contributing!

@tuannx

tuannx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Issue opened: #1834

On the history question — this is not from the migration. It goes back to
a977cfd5b,
which changed how the platform de-duplicates:

- get() = agents().flatMap { it.actions }.distinct()
+ get() = agents().flatMap { it.actions }.distinctBy { it.name }

- get() = agents().flatMap { it.goals }.toSet()
+ get() = agents().flatMap { it.goals }.distinctBy { it.name }.toSet()

Before: de-duplicate by value. Two identical goals collapse into one, which is
right.
After: de-duplicate by name. Two different goals sharing a name also collapse,
and one meaning is lost.

That is the only commit that ever touched those lines. conditions was already name-based before it.

The fix restores that distinction rather than removing the de-duplication:
elements equal by value are still collapsed, elements that merely share a name
are rejected at deploy time.

Sorry about the dense write-up. Issue and PR description are rewritten in
plainer terms.

One decision I would rather you made than inherited from a diff: deploy()
currently throws. I picked that because deploy() is an explicit call, so
silently not deploying seemed worse. The lighter option is to log an ERROR and
skip the conflicting agent, matching the direction in #1786 — existing apps keep
booting, but still lose the capability, just loudly. Happy to switch.

@igordayen

Copy link
Copy Markdown
Contributor

Thank you, @tuannx - will follow up. Best regards!

@igordayen

igordayen commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

One decision I would rather you made than inherited from a diff: deploy()
currently throws. I picked that because deploy() is an explicit call, so
silently not deploying seemed worse. The lighter option is to log an ERROR and
skip the conflicting agent, matching the direction in #1786 — existing apps keep
booting, but still lose the capability, just loudly. Happy to switch.

@alexheifetz - could you please advise - conflicting goals:

  • flag ERROR, continue booting
  • throw an exception and stop booting?

Should probably align with logic:

val achievableGoalValidationResult = AchievableGoalValidator().validate(agenticInfo.agentName(), targetType, instance, requireInterfaceDeserializationAnnotations)
        if(!achievableGoalValidationResult.isValid) {
            val errorMsg = achievableGoalValidationResult.errors.map { it.message }.joinToString { it }
            logger.error(errorMsg)
            return null
        }

from PR #1801
@deleSerna - FYI

@igordayen

igordayen commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Before: de-duplicate by value. Two identical goals collapse into one, which is
right.

@tuannx - could you please elaborate on "goal value"? Thank you.

@igordayen

Copy link
Copy Markdown
Contributor

@tuannx @deleSerna @alexheifetz - since we are not sure whether it is an exception or a flagging error, it is a better option - may I suggest considering something like ErroneousAgentExitPolicy with default behavior ERROR.
and have documented the property
embabel.agent.platform.exit-on-error
Would it work 4all?
Thanks

@deleSerna

deleSerna commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

from PR #1801

AchievesGoal without Action should definitely stop the Agent as it's spec violation and it can be easily fix while developing the Agent itself.
But the issue mentioned here seems a bit more tricky as goals/conditions can randomly be selected/dropped and that seems bad to me . We should throw an error if the developer can fix that duplicated goal/condition by renaming them but could the developer always do that?

Could these conflicting goals/action/conditions belongs to agents from third party libraries? If yes then I do not think there is a straight forward solution to this. But my knowledge here is limited. I always write Agent for a stand alone spring boot application. But, if Agent could also be in 3rd party library then just throwing an exception or just flagging an error also won't help as it's not actionable for the consumer of those conflicting libraries.

@igordayen

Copy link
Copy Markdown
Contributor

Could these conflicting goals/action/conditions belongs to agents from third party libraries? If yes then I do not think there is a straight forward solution to this. B

==> That's the reason for suggesting having an error policy configurable. thanks

@tuannx

tuannx commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor Author

@igordayen @deleSerna There's a third option, and the annotation path already does it. Annotated goals
are named after their agent:

name = "${stateClass.simpleName}.${method.name}"     // "WeatherAgent.myGoal"

and the tool naming strategy expects that shape:

/** "com.myco.MyAgent.myGoal" becomes "MyAgent_myGoal". */

So two annotated agents can both have a same goal and never collide. Only the
DSL takes the name literally — and AgentBuilder already holds the agent name:

    Goal(
(-)      name = name,
(+)       name = "${this@AgentBuilder.name}.$name",

That makes the collision impossible instead of reporting it better, and nobody
has to rename anything.

If you agree with the direction I'll rework this PR — the deploy-time check goes
away, and the test asserts both goals survive instead of asserting a rejection.

@deleSerna

Copy link
Copy Markdown
Contributor

That's the reason for suggesting having an error policy configurable.

@igordayen But that would not also fix the real issue when the conflicting actions/goal are coming from multiple agents right?

name = "${stateClass.simpleName}.${method.name}"

This could also still result in duplicate name as it's still simpleName not Name .
Even if we use 'Name, 'Name+ method.name still cause duplicate names unless we use signature. Therefore, we should make sure that Name+ method.name, still not esult in duplicate names within the agent itself.

@igordayen @alexheifetz IMO, we should go in the direction suggested by @tuannx but should use 'Name+ method.name` every where. But that looks like a much bigger change. Therefore, IMO, need a bit more thought before implementing it.

@igordayen

Copy link
Copy Markdown
Contributor

Three points raised:

  1. Error policy doesn't fully address the problem — Even with a configurable
    error policy, it wouldn't solve the
    collision when conflicting actions/goals come from multiple agents rather than
    within a single agent.
  2. simpleName still risks collisions — The proposed naming scheme
    ${stateClass.simpleName}.${method.name} uses simpleName, which can still
    duplicate. Even upgrading to Name (fully qualified), Name + method. name can
    still collide unless you include the full method signature.
  3. Recommendation: use Name + method.name everywhere, but with caution —
    @deleSerna agrees with @tuannx tuannx's direction and suggests using Name + method. name
    universally, but flags it as a bigger change that needs more thought before
    implementation.

@tuannx - concrete naming examples, please, to substantiate the idea and impact assessment.
Thanks

@igordayen

Copy link
Copy Markdown
Contributor

Consider renaming "fix(core): reject deployments that would silently drop goals or actions- #1833
" to "fix(core): reject deployments that would silently drop goals or actions DUE TO DUPLICATES" #1833

@tuannx

tuannx commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Before we choose between throwing, an error policy, or qualification, could we do a
small test-only PR first?

@igordayen asked for concrete naming examples and an impact assessment. We can generate
those instead of writing them by hand: a test that records every name Embabel publishes
externally into a checked-in file.

mcp-tool   StarNewsFinder_findNewsStories
a2a-skill  embabel_goal_com.embabel.examples.StarNewsFinder.findNewsStories

Any naming change then shows up as a diff, so we can see what breaks on the wire before
deciding anything.

We have changed these names unnoticed before: #599 (Claude Desktop rejected a tool name)
and #306 (the $embabel_agent_api suffix). Neither had a test.

It would also show that two agents in different packages with the same state class name
still produce the same MCP tool name, because the naming strategy keeps only the last
two segments.

Happy to open it. This PR would then rebase on top.

@igordayen

Copy link
Copy Markdown
Contributor

thanks @tuannx

Was actually inquiring about all patterns on validation logic in the agent validation package - what behavior do they expose by default? Is it consistent?

@deleSerna

Copy link
Copy Markdown
Contributor

Was actually inquiring about all patterns on validation logic in the agent validation package
Please compile full documentation on known agent validators behavior for consistency

DefaultAgentStructureValidator currently report errors ( agent booting won’t stop) for the following cases:

  • no actions, conditions, or goals defined
  • Missing goals
  • Duplicate action names
  • Actions has preconditions with multiple parameters ( not sure why this is an issue)

AgentMetadataReader stop the agent for following cases:

  • Missing EmbabelComponent or Agent annotation
  • Both @agentic and @agent annotations present
  • No description provided on the Agent
  • No actions, conditions, or goals defined
    • Duplicate as it already there on DefaultAgentStructureValidator
  • SuperVisor planner has more than one @AchievesGoal
  • If embabel.agent.platform.planner.restricted-goals is true then all goals should return same type.
  • @AchievesGoal cannot be applied to void-returning @action method

AgentMetadataReader reports errors for following case

  • No goal defined

GoapPathToCompletionValidator reports error for cases where it can not to the goal

I have not checked in other places

@igordayen do you mean this report?

@igordayen

igordayen commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

@deleSerna - thanks for the analysis. So, Duplicate action names ==> already in place, but the algorithm requires refinements.

What is the flow?

metadata reader ==> validator ==> deployer.

Maybe propagate errors up to the deployer, and at the deployer level apply a proper exit policy?

Looking for an architecturally sound flow.

thanks

@igordayen

Copy link
Copy Markdown
Contributor

@tuannx - Is this PR valid then? As the analysis attached to the issues clearly states that deployments should not be rejected.

@tuannx

tuannx commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@tuannx - Is this PR valid then? As the analysis attached to the issues clearly states that deployments should not be rejected.

Hi @igordayen: Updated PR to log error only, please review whenever you're available. Thank you!

@tuannx tuannx changed the title fix(core): reject deployments that would silently drop goals or actions fix(core): log an error when a duplicate name silently drops a goal, action or tool Aug 3, 2026
@igordayen igordayen added this to the 1.5.2-Release🔵 milestone Aug 18, 2026
@igordayen
igordayen requested a review from azanux August 28, 2026 15:52
@igordayen

Copy link
Copy Markdown
Contributor

@tuannx Ran by Claude:

findings (no blockers, all medium/low):

  1. allTools() implicit contract (confirmed) — returns empty when no remote goals, not platform tools. Both
    MCP publishers have the ifEmpty fallback, but a future caller won't know to add it. Consider renaming to
    goalToolsWithPlatformFallback() or documenting the empty-means-no-goals contract in the KDoc.
  2. Collision describe uses post-rename name (confirmed) — when FULL_HIERARCHY renames two tools to the
    same name, the diagnostic ERROR message shows the same new name for both retained and dropped entries.
    Consider capturing the original name before renaming: describe = { "original '${it.definition.name}'
    (${it::class.qualifiedName})" } — but it here is already renamed. The original names should be captured
    from resolvedTools before the rename step.
  3. reported set not resettable (plausible) — nanoTime suffix in tests is a reliable mitigation in
    practice. An @VisibleForTesting internal fun clearReported() would make test isolation explicit rather
    than accidental.
  4. 3rd+ element same name goes unlogged (confirmed) — minor diagnostic gap; the count in the single log
    line (reduced N candidates to 1) covers it numerically, but C's description is never shown.

=====================================
Code review(medium · 4 findings)
⎿  ●embabel-agent-api/src/main/kotlin/com/embabe [corre — allTools() returns emptyList() when
l/agent/tools/agent/PerGoalToolFactory.kt:12 ctness goalTools is empty, even though platformTools
6 ] may be non-empty. Both MCP publishers
compensate with ifEmpty fallbacks, but any
future caller that doesn't know this contract
will silently publish nothing.
● embabel-agent-api/src/main/kotlin/com/e [corre — In resolveTools the describe lambda uses
mbabel/agent/core/ToolConsumer.kt:206 ctness it.definition.name, which is already the
] hierarchy-prefixed name. When two distinct tools
rename to the same name, retained and dropped
show identical descriptions, giving no diagnostic
clue about the original conflicting tools.
●embabel-agent-api/src/main/kotlin/com/emb [test-c — reported is a JVM-level singleton. Tests use
abel/agent/core/support/nameCollisions.kt overage nanoTime() suffixes for uniqueness, which works
:368 ] in practice, but a test framework retry within
the same JVM with the same nanosecond could
suppress a collision log, silently turning an
assertion into a false negative.
●embabel-agent-api/src/main/kotlin/com/emba [corre — The reportKey is kind/context/name — the same
bel/agent/core/support/nameCollisions.kt:4 ctness for all pairwise collisions on the same name.
01 ] When 3+ elements share a name, only A-vs-B is
logged; A-vs-C shares the key and is
suppressed, so C's description never appears in
diagnostics.

@igordayen igordayen 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.

@tuannx - please resolve inquiries as "resolved"

val namingStrategy = processContext.platformServices.toolNamingStrategy()
val actionSignatures = tools.filterIsInstance<CurriedActionTool>().joinToString("\n") { tool ->
val signature = TypeSchemaExtractor.buildActionSignature(tool.action)
val owner = processContext.agentProcess.agent.name

@igordayen igordayen Sep 3, 2026 •

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.

no NPE danger?

explain logic, please add comments, add example in comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Outdated: SupervisorAgentFactory is no longer changed by this PR.

@igordayen

igordayen commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

@tuannx

From Codex:

  1. PerGoalToolFactory still publishes explicit export.name values , so two different agents can expose the same
    public tool name and collide. In publishedName, the discriminator == null && exportName != null branch returns
    exportName unchanged, and the multi-input branch qualifies exportName with the input discriminator rather than the
    owning agent ([PerGoalToolFactory.kt:177-185]). The PR’s own disabled test shows the exact failure: HippoWizard and
    IbisWizard both publish shared.export twice under FULLY_QUALIFIED ([ToolNamingIntegrationTest.kt:472-485]). That
    directly misses the stated requirement that AgentA.tool1 and AgentB.tool1 be different published tools.

  2. The shared LLM publication path still silently drops one tool when the same owner contributes two tools with the same
    simple name, instead of making them uniquely addressable. ToolConsumer.resolveTools qualifies first and then distinctBy
    { it.definition.name }, so any collision after naming is resolved by discarding one tool with no warning
    ([ToolConsumer.kt:136-147]). The PR adds an integration test documenting this current behavior: one of two forecast
    tools under DuplicateToolNameAgent is dropped ([ToolNamingIntegrationTest.kt:390-405]). If the goal is unique published
    tool names rather than “different only when owners differ,” this is still a behavioral hole.

Open question

  • Is duplicate export.name intended to remain a documented opt-out from uniqueness, or does the requirement apply to all
    published tools, including explicitly named goal exports? The current PR and its tests assume the former, but your
    problem statement reads like the latter.

@azanux - could you please provide feedback, thanks

@tuannx

tuannx commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@tuannx

From Codex:

  1. PerGoalToolFactory still publishes explicit export.name values , so two different agents can expose the same
    public tool name and collide. In publishedName, the discriminator == null && exportName != null branch returns
    exportName unchanged, and the multi-input branch qualifies exportName with the input discriminator rather than the
    owning agent ([PerGoalToolFactory.kt:177-185]). The PR’s own disabled test shows the exact failure: HippoWizard and
    IbisWizard both publish shared.export twice under FULLY_QUALIFIED ([ToolNamingIntegrationTest.kt:472-485]). That
    directly misses the stated requirement that AgentA.tool1 and AgentB.tool1 be different published tools.
  2. The shared LLM publication path still silently drops one tool when the same owner contributes two tools with the same
    simple name, instead of making them uniquely addressable. ToolConsumer.resolveTools qualifies first and then distinctBy
    { it.definition.name }, so any collision after naming is resolved by discarding one tool with no warning
    ([ToolConsumer.kt:136-147]). The PR adds an integration test documenting this current behavior: one of two forecast
    tools under DuplicateToolNameAgent is dropped ([ToolNamingIntegrationTest.kt:390-405]). If the goal is unique published
    tool names rather than “different only when owners differ,” this is still a behavioral hole.

Open question

  • Is duplicate export.name intended to remain a documented opt-out from uniqueness, or does the requirement apply to all
    published tools, including explicitly named goal exports? The current PR and its tests assume the former, but your
    problem statement reads like the latter.

@azanux - could you please provide feedback, thanks

@igordayen Both Codex findings are the two disabled tests in this PR. That is what those tests are for — they record what qualification by owner cannot fix, so it is visible instead of hidden. This is the point I have been trying to get across for a while.

  • export.name published as-is — by design. It is a public name the user chose for outside clients; qualifying it breaks what they asked for.
  • distinctBy dropping a tool — not new. It was already there before this PR. I only moved the rename before it, which renaming requires.

Also: keeping the action in the owner would not have fixed either one. All tools in one interaction go to one call for one action, so the owner is the same either way. Dropping it lost nothing.

Your question: keep export.name as an opt-out, but stop being silent. Two agents picking the same export name should be reported at deploy time, using the nameCollisions code we already have. Same for distinctBy — warn instead of dropping quietly.

@azanux azanux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi @tuannx, thanks for the work. The naming rule is fine.

My concern is how much code it needs.

Why the code is heavy: the rename happens at the end, right before the LLM call. After that point, the rest of the code no longer knows the tool. So we add helpers to find the old name again.

1. sanitize() encodes too much. It hex-encodes every char outside [a-zA-Z0-9] - even _ and -, which providers accept. So my.tool becomes my_2e_tool, and my_tool becomes my_5f_tool. A simple replace(Regex("[^a-zA-Z0-9_-]"), "_") is enough. We still need bound(): even simple names can pass 64 chars.

2. ToolNamingContext.kt:95 - runCatching { ... }.getOrDefault(LEGACY) is there only for the mocks. Fix the tests instead. This code hides real errors.

3. The rename runs in 4 places: AbstractLlmOperations, ToolResolutionHelper, ToolLoop, Streaming. That is why name() needs the is QualifiedTool check, and why DefaultToolDecorator has a names() helper. One rename point, earlier, removes both.

4. SupervisorAgentFactory:330 - signature.removePrefix(...) fixes the string after building it. Build the signature with the published name instead. Then CurriedActionTool.action can stop being internal.

5. PerGoalToolFactory - does 3 things at once (owner qualification, input-type discrimination, dedup changes). Also, firstOrNull { it.goals.any { g === goal } } scans every agent by reference - the platform should give the owner directly.

Suggestion: split into 4 PRs or comcar je ne vois aps l'intreret mits

To be clear: these are suggestions only, nothing mandatory. The code was just not easy to follow as a reader - that is the main reason for this comment.

(1) strategy + config + one rename point, (2) MCP goal tools, (3) input-type discrimination, (4) collision reporting for #1834.

On @igordayen's question: I agree on export.name. It is a public name the user chose - do not rewrite it. But if two agents pick the same name, report it at deploy time. Do not stay silent.

?.takeIf { it.isNotBlank() }
?.let { listOf(it, toolName) }
?: listOf(toolName)
bound(parts.joinToString("-") { sanitize(it) }, parts.joinToString("\u0000"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Only the owner needs escaping

sanitize is applied to both the owner (Agent) and the tool name. The join point is the first -, so escaping the owner alone is enough to keep the split unambiguous. Escaping the tool name buys no extra uniqueness.

It does cost length. Every _ or . grows from 1 to 4 characters, and real MCP tool names use underscores heavily (brave_web_search, read_file).

Example:

ToolNamingStrategy.FULLY_QUALIFIED
    .nameFor("com.acme.research.DeepResearchAgent", "brave_web_search")

// actual:   com_2e_acme_2e_research_2e_DeepResearchAgent-brave__38a4886d2533
// expected: com_2e_acme_2e_research_2e_DeepResearchAgent-brave_web_search

The encoded name is 67 characters, over MAX_NAME_LENGTH = 64, so bound() truncates it and appends a hash. The model can no longer tell which tool it is calling. Dropping the tool-side escaping brings the same name to 61 characters and it stays readable.

Suggestion: escape the owner as it is now, and on the tool side escape only characters a provider would reject (anything outside [a-zA-Z0-9_-]). That keeps _ and - at one character while every published name still matches ^[a-zA-Z0-9_-]{1,64}$.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed that escaping common characters makes names longer and less readable. However, escaping only the owner protects the owner/tool separator, not the encoding within the tool name.
For example, preserving _ while keeping the current encoding for . would produce:

read.file     → AgentA-read_2e_file
read_2e_file  → AgentA-read_2e_file

The current underscore escaping keeps these distinct. I suggest retaining it for now, or replacing it with a shared encoding rule that improves readability while preserving this distinction across all publication paths.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fair point on read.file vs read_2e_file. But that collision needs a . in a tool name, and that case is very rare today:

  • @LlmTool / @Tool names come from method names, which cannot contain ..
  • Spring AI's default MCP naming (McpToolUtils.format) already strips invalid characters, so an MCP read.file reaches us as readfile.
  • OpenAI and Anthropic reject . in tool names anyway.

So we would pay a real cost on every tool (brave_web_search → 67 chars → truncated + hash) to protect a name that almost never exists.

Proposal that keeps your guarantee:

  • Owner: keep the current escaping (it protects the - separator).
  • Tool name: if it already matches [a-zA-Z0-9_-], keep it as-is. Otherwise replace invalid characters and append a short hash of the original name (read.file → read_file_a1b2c3). Two different originals can no longer collide, and normal names stay readable.

Or simply reject invalid tool names with a clear error, which matches what you suggested for over-long export names.

ToolNamingStrategy.LEGACY_NAME_ONLY -> goalToolNamingStrategy.nameForGoal(goal)
ToolNamingStrategy.FULLY_QUALIFIED -> toolNamingStrategy.nameFor(ownerName, goal.name)
}
exportName != null -> toolNamingStrategy.nameFor(exportName, discriminator)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed with @tuannx on the principle: export.name is a public name the user chose for outside clients, so it should be published as-is, and a deploy-time warning on duplicates is the right answer rather than qualifying it.

One place does not follow that principle. With a single starting input type the name goes out untouched. With two or more it is passed through sanitize , so the pinned name is rewritten:

1 input type  -> my.export
2 input types -> my_2e_export-UserInput

The -UserInput suffix is expected - one goal becomes two tools and they have to differ. Turning my.export into my_2e_export is not. Adding an input type silently renames a tool an outside client may already be calling, which is the exact thing publishing it as-is was meant to protect.

If export names are trusted, they should be trusted on both branches: my.export and my.export-UserInput.

Related, same value: line 179 skips bound() as well, so an export name over 64 characters is never truncated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You’re right: preserving an explicit name for one input but encoding it for multiple inputs is inconsistent.

Current:  my.export → my_2e_export-UserInput
Proposed: my.export → my.export-UserInput

Preserving the explicit base consistently makes sense. However, adding the suffix still changes the public name, so this does not preserve existing calls to my.export when another input type is added. That behavior needs to be explicit in the contract.
If an explicit name, including its suffix, exceeds the publication target’s limits, I would prefer a clear validation error over silently shortening it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed on both: my.export-UserInput, and a clear error instead of truncation.

@tuannx

tuannx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Hi @tuannx, thanks for the work. The naming rule is fine.

My concern is how much code it needs.

Why the code is heavy: the rename happens at the end, right before the LLM call. After that point, the rest of the code no longer knows the tool. So we add helpers to find the old name again.

1. sanitize() encodes too much. It hex-encodes every char outside [a-zA-Z0-9] - even _ and -, which providers accept. So my.tool becomes my_2e_tool, and my_tool becomes my_5f_tool. A simple replace(Regex("[^a-zA-Z0-9_-]"), "_") is enough. We still need bound(): even simple names can pass 64 chars.

2. ToolNamingContext.kt:95 - runCatching { ... }.getOrDefault(LEGACY) is there only for the mocks. Fix the tests instead. This code hides real errors.

3. The rename runs in 4 places: AbstractLlmOperations, ToolResolutionHelper, ToolLoop, Streaming. That is why name() needs the is QualifiedTool check, and why DefaultToolDecorator has a names() helper. One rename point, earlier, removes both.

4. SupervisorAgentFactory:330 - signature.removePrefix(...) fixes the string after building it. Build the signature with the published name instead. Then CurriedActionTool.action can stop being internal.

5. PerGoalToolFactory - does 3 things at once (owner qualification, input-type discrimination, dedup changes). Also, firstOrNull { it.goals.any { g === goal } } scans every agent by reference - the platform should give the owner directly.

Suggestion: split into 4 PRs or comcar je ne vois aps l'intreret mits

To be clear: these are suggestions only, nothing mandatory. The code was just not easy to follow as a reader - that is the main reason for this comment.

(1) strategy + config + one rename point, (2) MCP goal tools, (3) input-type discrimination, (4) collision reporting for #1834.

On @igordayen's question: I agree on export.name. It is a public name the user chose - do not rewrite it. But if two agents pick the same name, report it at deploy time. Do not stay silent.

@azanux Thanks for these points.
The main design goal is one consistent, deterministic naming contract across LLM, supervisor, and MCP publication: the same owner, tool name, and strategy should produce the same published name.
I agree we should improve readability and fix the inconsistent handling of explicit export names. Any change should also preserve name disambiguation. Duplicate detection is a separate concern and should operate on the complete final list of published names.
cc @igordayen

@igordayen igordayen changed the title feat(core): apply FQN naming to published tools Feat(core): apply FQN naming to published tools Sep 18, 2026
@azanux

azanux commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks. @tuannx The goal is fine, no disagreement there. Point 1, export names and duplicates are covered in the inline threads.

remaining

  • runCatching in ToolNamingContext.forLlmCall hides real errors. Please fix the mocks instead.

@tuannx

tuannx commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Hi @azanux @igordayen,

Thank you both for the thoughtful feedback and discussion. Reflecting on the conversation, I completely agree that trying to guard against rare edge cases (like dots in tool names or collisions between read.file and read_2e_file) added unnecessary complexity—such as SHA-256 bounding and hex-encoding that unnecessarily penalized common names like brave_web_search.

To keep the PR focused, clean, and easy to maintain, I'd like to align on this simplified, fail-fast direction:

  1. ToolNamingStrategy (Fail-Fast & Clean):

    • Drop SHA-256 hashing and hex-encoding completely.
    • Enforce standard tool naming rules: ^[a-zA-Z0-9_-]+$ and max 64 characters.
    • Fail fast with a clear IllegalArgumentException if a tool name contains invalid characters or if the combined FQN (Owner-toolName) exceeds 64 characters.
    • Keep owner escaping to protect the - delimiter.
  2. Explicit export.name:

    • Preserve explicit export names directly (my.export and my.export-UserInput for multi-input goals), without rewriting them.
    • Fail fast if the resulting name exceeds 64 characters.
  3. Scope Boundaries:

  4. Housekeeping:

    • Remove runCatching in ToolNamingContext and update the test mocks properly so real errors aren't masked.

If this simplified approach looks good to you, I will proceed with updating the code, pruning the tests accordingly, and pushing the commit.

@azanux

azanux commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Hi @azanux @igordayen,

Thank you both for the thoughtful feedback and discussion. Reflecting on the conversation, I completely agree that trying to guard against rare edge cases (like dots in tool names or collisions between read.file and read_2e_file) added unnecessary complexity—such as SHA-256 bounding and hex-encoding that unnecessarily penalized common names like brave_web_search.

To keep the PR focused, clean, and easy to maintain, I'd like to align on this simplified, fail-fast direction:

  1. ToolNamingStrategy (Fail-Fast & Clean):

    • Drop SHA-256 hashing and hex-encoding completely.
    • Enforce standard tool naming rules: ^[a-zA-Z0-9_-]+$ and max 64 characters.
    • Fail fast with a clear IllegalArgumentException if a tool name contains invalid characters or if the combined FQN (Owner-toolName) exceeds 64 characters.
    • Keep owner escaping to protect the - delimiter.
  2. Explicit export.name:

    • Preserve explicit export names directly (my.export and my.export-UserInput for multi-input goals), without rewriting them.
    • Fail fast if the resulting name exceeds 64 characters.
  3. Scope Boundaries:

  4. Housekeeping:

    • Remove runCatching in ToolNamingContext and update the test mocks properly so real errors aren't masked.

If this simplified approach looks good to you, I will proceed with updating the code, pruning the tests accordingly, and pushing the commit.

Sounds good !! Thanks @tuannx

@tuannx
tuannx force-pushed the fix/agent-platform-name-collision branch from 6f18bcd to 335b4ef Compare September 24, 2026 15:59
@tuannx
tuannx requested review from azanux and igordayen September 24, 2026 18:19

@igordayen igordayen 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.

@tuannx - thanks for the next revision, i did not realize that it touches super critical artifacts in embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support package, which forces me to think more about how to make less intrusive and more manageable.

resolvedTools += resolution.resolvedToolGroup.tools
}
}
val publishedTools = resolvedTools.map(name)

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.

comment on how the outcome looks like

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Outdated: this PR no longer touches ToolConsumer or the LLM tool path. Only MCP goal tools are renamed.

…l tools

- Introduce ToolNamingStrategy (LEGACY_NAME_ONLY, FULLY_QUALIFIED) with fail-fast validation against [a-zA-Z0-9_-] and max 64 characters.
- In PerGoalToolFactory, qualify exported MCP goal tools with agent name under FULLY_QUALIFIED while preserving explicit export.name.
- Propagate tool naming strategy lookup errors in sync and async MCP callback publishers.
- Add configuration properties and documentation.

Signed-off-by: TuanNX <tuannx87@gmail.com>
@tuannx
tuannx force-pushed the fix/agent-platform-name-collision branch from 5949921 to e8ffb4f Compare September 26, 2026 02:54
@igordayen

Copy link
Copy Markdown
Contributor

@tuannx -getting challenging to follow code without at least a brief explanation of the recent commits.
AI-generated code without additional explanation is very hard to read, speaking about myself.
Till I am 100% clear on code behavior and structure, I would have follow up on more inquiries.
I would not rush, getting it right is more important than to adhere to release schedule. Thank you

@igordayen igordayen 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.

@tuannx - few more comments to consider, thank you

Comment thread embabel-agent-api/src/main/kotlin/com/embabel/agent/core/ToolNamingStrategy.kt Outdated
@tuannx

tuannx commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

@igordayen @azanux — latest revision, as simple as I could make it:

  1. Scope reduced to MCP goal tools only. No changes to LLM tool calls, the supervisor, PlatformServices, or spi/support. The MCP publishers read the strategy from AgentPlatformProperties.
  2. Bug fix: with FULLY_QUALIFIED, annotation goals (named com.acme.AgentA.search) failed at startup because of the dots. They now publish as AgentA-search. A test covers this.
  3. Removed the "already prefixed" shortcut in nameFor. runCatching / ToolNamingContext are gone.
  4. Moved ToolNamingStrategy to api.tool, and trimmed the docs down to one example table.

The PR description has a short before/after table. Duplicate detection remains in #1834.

…l names

Signed-off-by: TuanNX <tuannx87@gmail.com>
@tuannx
tuannx force-pushed the fix/agent-platform-name-collision branch from 2432a5d to f94a177 Compare September 26, 2026 09:21
@tuannx
tuannx requested a review from igordayen September 26, 2026 09:22
@igordayen

Copy link
Copy Markdown
Contributor

The PR description has a short before/after table

@tuannx - where is before / after table?

Could you please elaborate on:

same goal, input types UserInput + Order AgentA-search-UserInput, AgentA-search-Order

Thanks

@igordayen igordayen 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.

@tuannx it is good that scope has been trimmed. few comments, manual review. coding agent review to follow. Please ensure PR corresponds to proper issue. Thank you

require(toolName.matches(TOOL_NAME_REGEX)) {
"Invalid tool name '$toolName': must match [a-zA-Z0-9_-]"
}
val prefix = ownerName?.takeIf { it.isNotBlank() }?.let { sanitizeOwner(it) }

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.

please explain role of prefix by supplying examples

return published
}

/** Hex-escape owner chars outside ASCII letters/digits (e.g. `.` becomes `_2e_`) to keep owners distinct. */

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.

full example please

With `FULLY_QUALIFIED`:

* Goal `com.acme.AgentA.search` publishes as `AgentA-search`.
* A goal with several starting input types publishes one tool per type: `AgentA-search-UserInput`, `AgentA-search-Order`.

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.

example of goal, please, java or kotlin

startingInputTypes: Set<Class<*>> = setOf(UserInput::class.java, MagicVictim::class.java),
): List<GoalTool<*>> {
val agentPlatform = IntegrationTestUtils.dummyAgentPlatform()
agentPlatform.deploy(

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.

Is it a single or multiple goals?

}

@Test
fun `fully qualified naming keeps same goal names from different agents`() {

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.

KDOC please. why FQN would affect original goal names

)
}

@Test

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.

KDOC with example, name before / after

@tuannx

tuannx commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Closing this PR. Thanks @igordayen @azanux @deleSerna for the detailed reviews.

The change has grown hard to follow in one PR. I'll split it into smaller, self-contained PRs. Each one will go through self-review and a PR on my fork first, before I raise it here. Duplicate-name detection stays tracked in #1834.

@tuannx tuannx closed this Sep 26, 2026
@igordayen

Copy link
Copy Markdown
Contributor

FROM CODEX:

  • Default remains legacy naming via LEGACY_NAME_ONLY.

    • FULLY_QUALIFIED only affects MCP-published goal tools.
    • Explicit export.name is preserved, including dotted names like my.export.
    • Multi-input goals get discriminators under fully qualified naming.
    • Sync and async MCP publishers both thread the new AgentPlatformProperties.tools.namingStrategy through correctly.
    • Tests cover Kotlin, Java API visibility, Spring property binding, per-goal factory behavior, and MCP publisher integration.

    The main residual risk is already called out in the PR and follow-up issue Two agents using the same goal name: one goal is silently dropped #1834: duplicate detection is still incomplete. The PR can reduce common
    collisions, but it does not prove the final published tool-name set is globally unique. I would not block this PR on that, because the default behavior
    is unchanged and the limitation is documented.

    One small non-blocking suggestion: keep the docs very explicit that this is “agent-qualified MCP export naming,” not a general goal identity model. The
    implementation uses the owning agent name plus the goal’s short name, which is the right scope for this PR.

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.

5 participants