SDK
6. fejezet

Agent-koordináció – IAgentCoordinator

Domain és system szűréssel, valamint az 5. fejezetben látott felülírásokkal (buildRetrievalOptions(), a deep fallback hook-ok stb.) a plugin fejlesztőnek eddig arra volt ráhatása, hogyan viselkedjen a saját domain-jéhez tartozó orchestrátor. Az IAgentCoordinator ennél egy szinttel feljebb visz: lehetővé teszi, hogy egy tool a saját, tetszőlegesen összetett algoritmusa alapján maga tervezze meg, mely további tool-oknak kell együttműködniük egy feladat megválaszolásához - milyen rész-lépésekre bomlik a feladat, melyik rész-lépést melyik tool-nak kell feldolgoznia, és hogyan jut el az egyik rész-lépés eredménye a másikhoz. Ez a fejezet ezt a tudást gyűjti össze egy helyen: a rész-feladatokra bontás szerződését, a keletkező rész-lépés-fa végrehajtási szabályait, és a tool-ok közötti együttműködés összes, a platform által kínált csatornáját - a laza, opcionális kontextus-átvételtől a tool-ok közötti közvetlen, kódbeli metódushívásig.

6.1   A szerződés – IAgentCoordinator interfész

Egy IOrchestratorActivatedTool a saját algoritmusa szerint ismeri legjobban, milyen rész-lépésekre kellene bontani egy feladatot ahhoz, hogy a megválaszolásához esetleg szükséges további tool-ok is felismerjék, hogy rájuk is szükség van. Az IAgentCoordinator interfészt implementálva egy tool ezt a tudást a feladat leírásába írhatja bele, mielőtt a suitability teszt eldöntené, mely tool-ok aktiválódnak.

public interface IAgentCoordinator {
    TaskInfo decomposeIntoSubtasks(TaskInfo taskInfo) throws Exception;

    // Opcionális: döntés egy feltételesként megjelölt rész-lépés futtatásáról (6.4)
    default SubtaskDecision decideConditionalSubtask(TaskInfo parentTask, TaskInfo nextSubtask,
            Map<String, List<PromptAndContext>> completedSoFar) {
        return SubtaskDecision.RUN;
    }
}

Az implementáció maga dönti el, hogy a kapott feladat az ő algoritmusa szempontjából releváns-e: ha nem, a bemenetet változatlanul adja vissza. Ha igen, a kapott TaskInfo példányt módosítja - nem újat hoz létre és ad vissza -, mert az orchestrátor a feladat állapotát (a rész-lépéseket, a rajta már lefutott tool-ok kontextusát, egy esetleges folyamatban lévő tisztázó kört) magán ezen a példányon tartja karban, akár több, egymást követő tisztázó kérdés-válasz körön át is. Egy módosítás vagy a feladat saját szövegének (taskPrompt) kiegészítéséből áll, vagy a feladat rész-lépéseit tartalmazó subTasks mezőjének feltöltéséből - a kettő kombinálható.

@Override
public TaskInfo decomposeIntoSubtasks(TaskInfo taskInfo) throws Exception {
    MyDecompositionRequest decomposition =
        DECOMPOSITION_SCHEMA.exractToolParam(llmAssistant, objectMapper, taskInfo.getTaskPrompt());
    if (decomposition == null || !decomposition.isRelevant()) {
        return taskInfo;  // nem releváns - a feladat változatlan marad
    }

    TaskInfo firstStep = new TaskInfo(taskInfo.getTaskNo() + ".1",
        "Önálló, önmagában is érthető szövegű első rész-lépés",
        taskInfo.getImportantWords(), taskInfo.getPromptType());
    firstStep.setRequiredToolNames(Set.of("egyik_szukseges_tool_neve"));

    TaskInfo secondStep = new TaskInfo(taskInfo.getTaskNo() + ".2",
        "Önálló, önmagában is érthető szövegű második rész-lépés",
        taskInfo.getImportantWords(), taskInfo.getPromptType());
    secondStep.setRequiredToolNames(Set.of("masik_szukseges_tool_neve"));

    taskInfo.setSubTasks(List.of(firstStep, secondStep));
    return taskInfo;
}
💡
A coordinator implementációja tetszőleges IOrchestratorActivatedTool-on elhelyezhető - nem kell hozzá külön, csak erre a célra létrehozott tool-t írni. A rész-feladatokra bontás algoritmusa így ugyanabban az osztályban él, amelyik a feladatot ténylegesen fel is dolgozza.

6.2   Rész-lépések fája és végrehajtási sorrend

A subTasks mező TaskInfo-k listája - minden rész-lépés a szülőjéhez hasonló, önálló feladat: saját azonosítóval (a szokásos konvenció a szülő azonosítójának egy pontozott utótaggal való kiegészítése, pl. "1.1"), saját, önmagában is érthető taskPrompt-tal. Egy rész-lépés maga is rendelkezhet további, ugyanígy feldolgozandó rész-lépésekkel - fát alkotva, egy beépített mélységi korlátig.

Ha egy feladatnak van rész-lépése, az orchestrátor a feladat saját suitability tesztje és tool-aktiválása előtt rekurzívan, listasorrendben, egyenként maradéktalanul lezárja mindegyik rész-lépést - beleértve egy rész-lépés által esetlegesen igényelt tisztázó kérdés-válasz kört is. Ha egy rész-lépés tisztázó kérdést igényel, vagy önmaga ad terminális választ, a feldolgozás ott megszakad, és az eredmény felfelé propagálódik - a szülő feladat (és annak testvérei) csak azután folytatódik, hogy minden rész-lépés lezárult. Egy rész-lépésen aktiválódott tool(ok) eredménye a rész-lépés saját taszk-azonosítója alatt kerül abba a kontextus-pufferbe, amit a szülő feladat a saját tool-aktiválása előtt megkap - egy erre épülő tool tehát ugyanúgy hozzáfér a rész-lépés eredményéhez, mintha egy alacsonyabb prioritású tool állította volna elő ugyanabban a körben (lásd 6.7).

Az executeOrchestratorActivatedTools() a domain aktiválható tool-jai közül azokat, amelyek IAgentCoordinator-t is implementálnak, priority szerint növekvő sorrendben, egymás után futtatja a feladaton - mindegyik az előző kimenetét kapja bemenetként -, mielőtt a feladat saját suitability tesztje eldöntené, mely tool-oknak kell aktiválódniuk rajta. Ez a lépés minden körben lefut, friss hívásnál és egy folyamatban lévő tisztázó kör folytatásakor is - a feladat saját appliedCoordinatorNames halmaza (lásd alább) önmaga gondoskodik arról, hogy egy már egyszer lefuttatott coordinator ne fusson le újra ugyanazon a feladaton. Egy coordinator hibája esetén a feladat az adott coordinator előtti állapotában marad, a további coordinatorok futása nem szakad meg.

6.3   appliedCoordinatorNames – egy coordinator legfeljebb egyszer fut le egy feladaton

A TaskInfo.appliedCoordinatorNames azoknak a coordinator tool-oknak a nevét tartalmazza, amelyek ezen a feladaton - vagy valamelyik ősén - már megkapták az esélyt a rész-lépésekre bontásra, függetlenül attól, hogy éltek-e vele. Ha legalább egy coordinator lefutott egy körben, a neve bekerül ebbe a halmazba, és ugyanez a - immár bővített - halmaz kerül be magára a feladatra és annak frissen létrejött összes rész-lépésére is. Ez két dolgot biztosít: egy adott coordinator sem magán a feladaton, sem egyetlen leszármazottján nem fut le újra, miközben egy még nem próbált coordinator mindkettőn továbbra is lefuthat - és mivel a halmaz a feladat saját, tartós állapotán él, ez több, egymást követő tisztázó kör között is megmarad.

6.4   requiredToolNames – kényszerített, suitability teszt nélküli aktiválás

Egy coordinator gyakran már a bontás pillanatában, pontosan tudja, melyik tool-nak kell aktiválódnia egy adott (rész-)feladaton - nem kell ezt egy önálló szöveg-alapú suitability teszt eldöntésére bíznia. A TaskInfo.requiredToolNames halmazba írt tool-nevek pontosan ezt fejezik ki: ha ez a halmaz nem üres, a feladat tool-aktiválási döntése kizárólag ebből épül fel - a szokásos, LLM-alapú alkalmassági vizsgálat ki is marad. Ez különösen egy önállóan létrehozott rész-lépésnél hasznos, ahol a taskPrompt szövege önmagában esetleg nem biztos, hogy egy suitability tesztet meggyőzően aktiválna, miközben a coordinator saját algoritmusa szerint egyértelmű, melyik tool-nak kell futnia rajta.

A kényszerítés kizárólagos: ha a halmaz nem üres, a feladaton csak a megjelölt tool-ok futnak, a domain RAG retrieval tool-ja sem fut mellettük. A halmazba a tool egyszerű neve (getName()) és a tool-registry által domain-előtaggal kiegészített neve egyaránt írható - az orchestrátor mindkét alakot felismeri.

Ha egy tool kizárólag coordinator által kijelölt rész-lépésen futhat, felhasználói kérdésre soha, nem kell az orchestrátor kizárt tool-jai közé tenni: elég, ha a getExecutableInstruction() igen/nem kérdése mindig nemleges választ ír elő (pl. "my_tool: mindig false – csak koordinátor által kijelölt feladaton fut"). A suitability teszt így soha nem aktiválja, a requiredToolNames viszont igen.

Feltételes rész-lépések – decideConditionalSubtask()

Előfordul, hogy egy rész-lépésre csak akkor van szükség, ha egy korábbi testvére nem adott eredményt - pl. egy olcsó, szűk keresés után csak akkor kell egy drága, teljes keresés, ha az előbbi nem talált választ. Ilyenkor a coordinator a rész-lépést TaskInfo.setConditional(true)-val feltételesként jelöli meg. Az orchestrátor közvetlenül egy feltételes rész-lépés indítása előtt meghívja a rész-lépést létrehozó coordinator decideConditionalSubtask() metódusát, és átadja a testvér rész-lépések addigi eredményének pillanatképét (kulcs: taskNo:toolName completion-kulcs). A visszaadott SubtaskDecision:

ÉrtékHatás
RUNA rész-lépés lefut (alapértelmezés)
SKIPA rész-lépés elmarad, a további rész-lépések feldolgozása folytatódik
STOPA rész-lépés és az összes hátralévő testvére elmarad; a szülő feladat saját tool-aktiválása lefut

Hogy melyik coordinator dönt, azt a rész-lépés ownerCoordinatorName mezője rögzíti - ezt az orchestrátor tölti ki a bontás után, a coordinatornak nem kell beállítania. Ha a döntés hibát dob, a rész-lépés lefut.

TaskInfo cheapStep = new TaskInfo(taskInfo.getTaskNo() + ".1.n", query, words, type);
cheapStep.setRequiredToolNames(Set.of(neighborSearchTool.getName()));

TaskInfo fullStep = new TaskInfo(taskInfo.getTaskNo() + ".1.s", query, words, type);
fullStep.setRequiredToolNames(Set.of(searchTool.getName()));
fullStep.setConditional(true);   // csak akkor fut, ha a szűk keresés nem adott választ

@Override
public SubtaskDecision decideConditionalSubtask(TaskInfo parentTask, TaskInfo nextSubtask,
        Map<String, List<PromptAndContext>> completedSoFar) {
    String pairedPrefix = nextSubtask.getTaskNo().replace(".s", ".n") + ":";
    boolean answered = completedSoFar.entrySet().stream()
        .filter(e -> e.getKey().startsWith(pairedPrefix))
        .anyMatch(e -> e.getValue().stream().anyMatch(pac -> pac.getContext() != null && !pac.getContext().isEmpty()));
    return answered ? SubtaskDecision.SKIP : SubtaskDecision.RUN;
}

6.5   promptContext – a végrehajtási szöveg elválasztása a suitability tesztektől

Amikor egy coordinator rész-lépéseket hoz létre, az orchestrátor minden frissen létrejött rész-lépés promptContext mezőjét automatikusan feltölti a szülő feladat saját, végrehajtásra kész szövegével. Ez a mező csak a tool tényleges végrehajtásakor - a taskPrompt elé fűzve - kerül át a ténylegesen átadott szövegbe; sem a suitability teszt, sem egy további coordinator saját bontási döntése nem látja, kizárólag a taskPrompt mezőt. Ez teszi lehetővé, hogy egy rész-lépés taskPrompt-ja rövid, önmagában is érthető direktíva maradjon, miközben a ténylegesen futó tool a teljes, eredeti kontextust is megkapja.

6.6   AGENT_COORDINATOR_MARKER és isCoordinatorRequested()

A ténylegesen végrehajtásra átadott szöveg - ha a feladatnak van promptContext-je - tartalmazza az IChatbotTool.AGENT_COORDINATOR_MARKER jelölő-szót is, ami azt jelzi, hogy a szöveg (részben vagy egészben) egy coordinator által meghatározott feladatot hordoz, nem kizárólag a felhasználó saját, közvetlen üzenetét. Egy tool, amelynek a logikája (pl. egy suitability-szöveg vagy egy paraméter-kinyerési instrukció) különbséget tenne a felhasználó saját megfogalmazása és egy coordinator direktívája között, az isCoordinatorRequested(text) alapértelmezett metódussal ellenőrizheti ezt a kapott szövegen, jelölő-szó kézi keresése helyett.

A végrehajtási szöveg két része az AbstractOrchestratorTool statikus segédeivel olvasható ki: az extractCoordinatorTask(text) a coordinator által meghatározott feladatot, az extractCoordinatorOriginal(text) a felhasználó eredeti üzenetét, vagyis a szülő feladat szövegét adja vissza. Ha egy coordinator a rész-lépései számára strukturált adatot is átad, érdemes a szülő feladat szövegét szerializált JSON-ként megadni - a rész-lépéseken futó tool-ok így az extractCoordinatorOriginal() eredményéből közvetlenül deszerializálhatják, szöveges feldolgozás nélkül.

Egy tool a jelölő alapján a viselkedését is igazíthatja: coordinator kérésére a szokásos kontextus helyett a rész-lépés feladatára adott kész választ adhatja vissza, ToolExecutionResult.asGuaranteedContext()-ként. A coordinator így válaszokat gyűjt a rész-lépéseiről, nem nyers kontextust, és a válaszok garantált kontextusként kerülnek a hívó elé.

6.7   Kontextus-megosztás a rész-lépések között

A rész-lépéseken aktiválódott tool-ok eredménye automatikusan bekerül abba a kontextus-pufferbe, amit a szülő feladat saját tool-aktiválása előtt megkap - ehhez a coordinatornak semmit nem kell tennie. Ha a szülő feladaton aktiválódó tool-nak ehhez a pufferhez hozzá kell férnie (pl. hogy a rész-lépésekben feloldott adatokra építsen), az IContextConsumerTool interfészt kell implementálnia. Gyakori eset, hogy ez maga a coordinator: a rész-lépések lezárulása után a szülő feladaton ő aktiválódik, és a rész-lépések eredményéből állítja össze a sajátját.

Az orchestrátor egy IContextConsumerTool-t mindig az executeWithPriorContext(...) metódusán keresztül hajt végre, és a puffer addigi állapotának pillanatképét paraméterként adja át - a tool-ok singleton példányok, ezért a kapott kontextust nem szabad példánymezőben tárolni. A kontextus nélküli executeWithResult(...) meghívása egy ilyen tool-on hiba: az interfész alapértelmezése UnsupportedOperationException-t dob. Ha a tool tisztázó kérdést is feltehet, az IClarifiableContextConsumerTool-t implementálja: a tisztázó kör folytatása ott is a kontextussal történik (executeWithClarificationAndPriorContext(...)). A szerződést és egy teljes példát a 4. fejezet, 4.8 tárgyal, mert ez a mechanizmus nem kizárólag coordinator által létrehozott rész-lépésekkel működik, hanem bármely, ugyanabban a feldolgozási körben korábban lefutott tool kimenetével.

6.8   Közvetlen hívás két tool között

A rész-lépésekre bontás mindig az orchestrátor teljes gépezetén megy keresztül: suitability-döntés (vagy requiredToolNames), kontextus-puffer, esetleges tisztázó kör. Ha egy tool-nak egy másik, ugyanabban a Spring context-ben élő tool egy konkrét, előre ismert metódusára van szüksége - nem arra, hogy az a másik tool a saját, önálló aktiválási logikája szerint fusson le -, ennél egyszerűbb út is rendelkezésre áll: a hívott tool közönséges @Autowired mezőként injektálható a hívó tool-ba, és a szükséges metódusa közvetlenül, szinkron Java-hívással elérhető.

@Component
public class InteractionAnalysisTool implements IChatbotTool<InteractionRequest>, IAgentCoordinator {

    @Autowired
    private SubstanceLookupTool substanceLookupTool;

    private void analyzeCandidate(String candidateLabel, List<UniqueTextDocument> pages) {
        // Nem az orchestrátoron, nem tool-aktiválásként - egyszerű metódushívás
        // egy másik, Spring által kezelt tool-ra, amelynek publikus, erre a célra
        // szolgáló metódusa van.
        List<List<String>> substanceGroups =
            substanceLookupTool.resolveSubstanceGroups(candidateLabel, pages);
        // ... a kapott hatóanyag-csoportokkal folytatja a saját elemzését
    }
}

Ez a minta akkor helyénvaló, ha a hívott logika nem önálló, orchestrátor szintű döntés eredménye (nincs suitability kérdés, nincs tisztázó kör, nincs prioritási sorrend), hanem egy tisztán funkcionális, paramétereiből determinisztikusan következő lépés, amit a hívó tool a saját algoritmusának egy adott pontján, pontosan tudva, mikor és mivel akarja meghívni. A hívott metódus ugyanúgy egy közönséges, tesztelhető, singleton scope-ú Spring bean metódusa, mint bármely más service - a hívó tool felelőssége, hogy a hívás a saját végrehajtásának megfelelő pontján történjen, mert az orchestrátor semmilyen bekötést, sorrendet vagy kontextus-pufferelést nem végez helyette.

⚠️
Közvetlen hívással a hívott tool getPriority(), suitability vagy requiredToolNames alapú aktiválási logikája nem fut le - a hívó egyenesen a metódustörzsét futtatja. Ha a hívott logikának mégis szüksége lenne ezekre (pl. hogy tisztázó kérdést tehessen fel a felhasználónak), a rész-lépésekre bontás (6.2) vagy az IToolDependent (4.8) a megfelelő eszköz, nem a közvetlen hívás.

6.9   Melyik együttműködési mechanizmust mikor

A platform négy, egymást kiegészítő módot kínál arra, hogy egy tool egy másik tool munkájára építsen - a különbség abban van, mennyire szoros a függés, és ki dönt arról, hogy a másik tool egyáltalán lefusson-e.

MechanizmusKi dönt a másik tool lefutásárólMikor használd
IContextConsumerTool A szokásos suitability teszt (vagy egy coordinator) - nincs garancia A másik tool kimenete csak kiegészíti az eredményt; hiányában is értelmes, csak esetleg általánosabb választ ad (4.8)
IToolDependent Az orchestrátor - garantáltan, fix, kódban rögzített tool-halmazra A működés a másik tool kimenete nélkül értelmezhetetlen, és ez a függés minden aktiváláskor ugyanaz (4.8)
IAgentCoordinator A coordinator saját, futásidejű döntése - dinamikusan létrehozott rész-lépésekre, egyenként kényszeríthető tool-lal A szükséges tool-ok köre és sorrendje a feladat tartalmától függ, nem rögzíthető előre kódban - ez a fejezet tárgya
Közvetlen @Autowired hívás Kizárólag a hívó tool saját, kódbeli logikája - az orchestrátor nem vesz részt benne Egy konkrét, előre ismert, funkcionális metódusra van szükség, orchestrátor-szintű aktiválási döntés nélkül (6.8)

A platform kész mélykereső coordinatort ad deep fallback-hez (5. fejezet): ha a kontextus-kapuőr szerint az elsődleges retrieval kontextusa nem elegendő a kérdés pontos megválaszolásához, a coordinator a hiányzó információra célzott kereséseket indít a domain kereső tool-jaival, és az így talált válaszokkal egészíti ki a kontextust. A működése a platform része; a plugin fejlesztőjének csak a bekötése és a paraméterezése a feladata.

Bekötés

Az orchestrátor getDeepFallbackTool() metódusa ne közvetlenül a kontextuskereső tool-t adja vissza, hanem egy AbstractDeepSearchCoordinatorTool leszármazottat, amely megnevezi a domain kereső tool-ját:

@Component
public class MyDeepSearchCoordinatorTool extends AbstractDeepSearchCoordinatorTool {

    @Autowired
    private MyContextSearchTool contextSearchTool;          // AbstractContextSearchTool

    @Autowired
    private MyNeighborPageSearchTool neighborPageSearchTool; // AbstractNeighborPageSearchTool

    @Override
    protected IChatbotTool<?> getSearchTool() {             // kötelező
        return contextSearchTool;
    }

    @Override
    protected IChatbotTool<?> getNeighborSearchTool() {     // opcionális
        return neighborPageSearchTool;
    }
}

// A domain orchestrátorában:
@Override
protected IChatbotTool<?> getDeepFallbackTool(PromptType type) {
    return deepSearchCoordinatorTool;
}

A coordinatort és a kereső tool-okat nem kell kizárt tool-ként megadni: normál ágon nem aktiválódnak. A kereső tool-ok getExecutableInstruction() kérdése mindig nemleges választ írjon elő (pl. "my_tool: mindig false – csak koordinátor által kijelölt feladaton fut").

Paraméterezés

HolMit
getNeighborSearchTool() Opcionális, olcsóbb első lépés: egy AbstractNeighborPageSearchTool leszármazott, amely a kontextus releváns oldalainak közvetlen környezetében keres, mielőtt a kereső tool a teljes domainen keresne. Akkor érdemes, ha a hiányzó információ jellemzően ugyanabban a dokumentumban, a megtalált rész közelében van (pl. hosszú, összefüggő szövegek).
AbstractNeighborPageSearchTool.getNeighborSearchWindow(String system) Az első, szűk környezet félszélessége oldalban, system-enként (alapértelmezés: 5).
spring.retriever.deep-search-max-tasks A coordinator által indítható keresések felső korlátja (környezeti változó: DEEP_SEARCH_MAX_TASKS, alapértelmezés: 3).
spring.retriever.context-sufficiency-threshold A kapuőr küszöbe, amely alatt a mélykeresés elindul (alapértelmezés: 60).

Nagy domain

Ha a domain mérete miatt a teljes domainre kiterjedő keresés nem megengedhető, a kontextuskereső tool leszármazottja írja felül a getMaxBruteForcePageCount() metódust: beállított érték esetén a szűretlen, teljes átvizsgálás nem fut le, és a gráf- vagy kulcsszavas keresés ennél több oldalas találatára sem indulnak a drága LLM-hívások. Ha egy kérdéstípusra egyáltalán nem kell mélykeresés, az orchestrátor getDeepFallbackTool(PromptType) metódusa arra a típusra null-t adjon vissza (5. fejezet).