v0.9.0 : attente unique des délégations et contrôles de MIAM
- reprise lancée une fois en arrière-plan ; suivre et sleep refusés pendant l'attente - réveil lu par le plugin : résultat et conduite selon fin, gain compté à ce moment - lecture entière d'une sortie au verdict valide refusée, sauf chemins sensibles (réglage chemins_sensibles) - compteurs d'attentes, d'essais et d'échecs de contrôle dans /miam-economies - skill deleguer : controle, attendus, reprise et tableau des fin ; README et cahier à jour Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d3885a735
commit
2ce4af8963
8 files changed
+513
-28
No files matched your search
@@ -331,6 +331,11 @@ curl -s -H @"<logs>/mcp/attente.hdr" "http://127.0.0.1:<port>/attendre?demande_i
|
||||
|
||||
### À coder dans le plugin (v0.9.0), par bouts
|
||||
|
||||
Fait le 03/11/2026 (bouts 1 à 7, `hooks/attente.ts` et `register.tsx`, tests sur réponses figées), avec deux écarts :
|
||||
le réveil est lu par un hook `prompt.submit`, qui reconnaît la notification à l'id de la commande d'arrière-plan relevé
|
||||
au lancement et lit lui-même la sortie (une ligne JSON) ; PowerShell est refusé pour `reprise`, en syntaxe POSIX.
|
||||
Pas encore essayé sur le vrai serveur.
|
||||
|
||||
1. **Hook PostToolUse sur `deleguer`** : si `ok` est `null` et qu'il y a une `reprise`, ajouter au contexte « lance `reprise` une fois avec `run_in_background`, puis termine ton tour ou fais autre chose », et noter `demande_id` comme en attente dans l'état du tour.
|
||||
2. **Hook PreToolUse sur `Bash`** : reconnaître une commande qui contient `/attendre?demande_id=` ; sans `run_in_background`, la refuser (« lance-la en arrière-plan ») ; avec, passer la demande à « attendue ».
|
||||
3. **Hook PreToolUse sur `suivre`** : refuser si la demande est « attendue », avec « tu seras réveillé à la fin ». Refuser aussi les `sleep` et les boucles d'attente en `Bash` pendant une attente. Laisser passer `suivre` si `fin` vaut `besoin_claude` (permission à donner).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "miam",
|
||||
"displayName": "MIAM",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : règles de délégation, gains en tokens et usage Claude (5 h, hebdo) en couleur",
|
||||
"author": {
|
||||
"name": "Ange-Marie MAURIN"
|
||||
@@ -21,6 +21,12 @@
|
||||
"description": "Refuse à l'agent principal la lecture entière d'un long fichier et retient les longues sorties Bash/PowerShell, tant que MIAM répond",
|
||||
"default": true
|
||||
},
|
||||
"chemins_sensibles": {
|
||||
"type": "string",
|
||||
"title": "Chemins sensibles",
|
||||
"description": "Fichiers relus en entier même après un verdict valide de MIAM (concurrence, sécurité), séparés par des virgules ; « * » pour n'importe quels caractères",
|
||||
"default": "src/miam/services/mcp.py, src/miam/services/instances.py, src/miam/core/aio.py, *sandbox*"
|
||||
},
|
||||
"jeton": {
|
||||
"type": "string",
|
||||
"title": "Jeton du serveur MCP de MIAM",
|
||||
|
||||
@@ -36,6 +36,7 @@ Puis : à l'activation, Claude Code demande les réglages (voir section suivante
|
||||
| jeton | (vide) | Jeton Bearer du serveur MCP de MIAM, rangé dans le coffre d'identifiants. |
|
||||
| port | 8090 | Port du serveur MCP de MIAM sur 127.0.0.1. |
|
||||
| incitation | oui | Ajoute au prompt système une consigne de délégation (quelques centaines de tokens, en cache) et un rappel après une grosse lecture. |
|
||||
| chemins_sensibles | `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py`, `*sandbox*` | Fichiers relus en entier même après un verdict `valide` (concurrence, sécurité), séparés par des virgules. |
|
||||
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
|
||||
|
||||
## Utilisation
|
||||
@@ -53,6 +54,16 @@ Avec `imposer` (par défaut), et tant que MIAM répond, l'agent principal ne peu
|
||||
- `Read` d'un fichier de 20 000 octets ou plus est refusé avant la lecture, sauf plage de 400 lignes au plus (`offset`, `limit`), image, PDF ou fichier écrit par une délégation ;
|
||||
- une sortie `Bash` ou `PowerShell` de 20 000 caractères ou plus est enregistrée dans `.claude/miam-sorties/` du projet (dossier ignoré par git) et retenue : le modèle reçoit le chemin, à passer à `deleguer`. Une commande en erreur garde sa sortie et déclenche le rappel.
|
||||
|
||||
## Attente unique
|
||||
|
||||
Une délégation pas finie en 45 s renvoie `reprise`, une commande `curl` qui bloque sur MIAM jusqu'à l'état final. Le plugin dit à l'agent principal de la lancer une fois en arrière-plan (Bash, `run_in_background`), puis :
|
||||
- refuse de la lancer au premier plan ou avec PowerShell ;
|
||||
- refuse `suivre` et les `sleep` tant que la demande est attendue (`suivre` reste permis quand MIAM attend une décision, `fin: besoin_claude`) ;
|
||||
- au réveil, lit lui-même la sortie de la commande et donne le résultat et la conduite selon `fin` (`valide`, `termine`, `echec_controle`, `besoin_claude`, `annule`, `perdue`) : une délégation coûte un lancement et un réveil ;
|
||||
- après un verdict `valide`, refuse la lecture entière du fichier `sortie` (plage de 400 lignes au plus), sauf chemin sensible.
|
||||
|
||||
`/miam-economies` compte les attentes uniques, les essais et les échecs de contrôle de la session. Avec `imposer` désactivé, rien n'est refusé.
|
||||
|
||||
Les subagents ne sont pas concernés. Si MIAM ne répond pas, ou si la sortie ne peut pas être enregistrée, rien n'est bloqué. Si MIAM répond mais que la délégation échoue (`/mcp` à reconnecter, par exemple), la lecture par plages reste possible.
|
||||
|
||||
## Ligne d'état : gains et usage Claude
|
||||
@@ -93,6 +104,8 @@ hooks/
|
||||
hooks.json
|
||||
register.tsx # compteur de tokens, ligne d'état, incitation, /miam-economies
|
||||
economies.ts # calcul pur, testé
|
||||
attente.ts # attente unique et conduite selon `fin` : calcul pur, testé
|
||||
contrainte.ts # seuils des lectures et sorties longues
|
||||
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
|
||||
*.test.ts(x) # tests : claude plugin test plugins/miam
|
||||
LICENSE
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import {
|
||||
conduite, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin, sensible,
|
||||
} from './attente'
|
||||
|
||||
const REPRISE = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-1a2b"'
|
||||
|
||||
test('la commande reprise désigne sa demande, une autre commande aucune', () => {
|
||||
expect(demandeAttendue(REPRISE)).toBe('dem-1a2b')
|
||||
expect(demandeAttendue('curl -s http://127.0.0.1:8090/etat')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('sleep, Start-Sleep et timeout /t sont des attentes, pas un mot qui les contient', () => {
|
||||
expect(['sleep 30', 'while true; do sleep 5; done', 'Start-Sleep -Seconds 10', 'timeout /t 20'].map(estSommeil))
|
||||
.toEqual([true, true, true, true])
|
||||
expect(['ls asleep', 'grep sleeper src', 'pytest -k timeout'].map(estSommeil)).toEqual([false, false, false])
|
||||
})
|
||||
|
||||
test('lireFin prend la dernière ligne JSON avec un fin connu', () => {
|
||||
const sortie = 'bruit\n{"fin":"bizarre"}\n{"ok":true,"demande_id":"dem-1","verdict":"valide","fin":"valide"}\n'
|
||||
expect(lireFin(sortie)).toMatchObject({ fin: 'valide', demande_id: 'dem-1' })
|
||||
expect(lireFin('curl: (7) Failed to connect')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('finDe : seulement ce que MIAM a contrôlé', () => {
|
||||
expect(finDe({ ok: true, verdict: 'valide' })).toBe('valide')
|
||||
expect(finDe({ ok: true, verdict: 'non_controle' })).toBe('termine')
|
||||
expect(finDe({ ok: true })).toBeUndefined() // ancien MIAM, sans contrôle
|
||||
expect(finDe({ ok: false, erreur: 'echec_controle : lint : F401' })).toBe('echec_controle')
|
||||
expect(finDe({ ok: false, erreur: 'aucun preset testé' })).toBeUndefined()
|
||||
expect(finDe({ ok: null })).toBeUndefined()
|
||||
})
|
||||
|
||||
test('conduite : une ligne par fin, avec ce qui sert à agir', () => {
|
||||
expect(conduite({ fin: 'valide', demande_id: 'd', essais: 2, sortie: 'E:/p/x.py' }))
|
||||
.toContain('résultat écrit dans E:/p/x.py')
|
||||
expect(conduite({ fin: 'echec_controle', erreur: 'echec_controle : lint : F401' })).toContain('lint : F401')
|
||||
expect(conduite({ fin: 'echec_controle', controles: [{ nom: 'symboles', ok: false, message: 'champ x' }] }))
|
||||
.toContain('symboles : champ x')
|
||||
expect(conduite({ fin: 'besoin_claude' }, REPRISE)).toContain(REPRISE)
|
||||
for (const fin of ['termine', 'annule', 'perdue'] as const) {
|
||||
expect(conduite({ fin }).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('lecture entière sans limit bornée ; chemins sensibles par fin de chemin ou motif', () => {
|
||||
expect([{}, { limit: 2000 }, { offset: 10, limit: 50 }].map(lectureEntiere)).toEqual([true, true, false])
|
||||
expect(sensible('E:\\dev\\miam\\src\\miam\\services\\mcp.py')).toBe(true)
|
||||
expect(sensible('E:/dev/miam/src/miam/infra/sandbox_shell.py')).toBe(true)
|
||||
expect(sensible('E:/dev/miam/src/miam/services/mcp_card.py')).toBe(false)
|
||||
expect(sensible('E:/p/a.py', ['*.py'])).toBe(true)
|
||||
})
|
||||
|
||||
test('fichier de sortie nommé par la notification', () => {
|
||||
const n = '<task-notification><task-id>b7</task-id><output-file>C:\\Temp\\b7.output</output-file>' +
|
||||
'<status>completed</status></task-notification>'
|
||||
expect(fichierSortie(n)).toBe('C:\\Temp\\b7.output')
|
||||
expect(fichierSortie('rien')).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// Attente unique d'une délégation (contrat de MIAM : docs/hooks-delegation.md, section 11).
|
||||
//
|
||||
// Pas finie dans `call_wait_s`, une délégation renvoie `reprise`, une commande curl qui bloque sur GET /attendre jusqu'à
|
||||
// l'état final. Lancée une fois en arrière-plan, elle réveille Claude à sa sortie : aucun `suivre` ni `sleep` entre-temps.
|
||||
// Calcul pur, testé ; register.tsx branche ces fonctions sur les hooks.
|
||||
|
||||
import { PLAGE_MAX } from './contrainte'
|
||||
|
||||
export type Fin = 'valide' | 'termine' | 'echec_controle' | 'besoin_claude' | 'annule' | 'perdue'
|
||||
|
||||
export type Controle = { nom: string; ok: boolean; message?: string }
|
||||
|
||||
// Réponse de GET /attendre : celle de `suivre`, plus `fin`.
|
||||
export type Final = {
|
||||
fin: Fin
|
||||
demande_id?: string
|
||||
ok?: boolean | null
|
||||
verdict?: string
|
||||
controles?: Controle[]
|
||||
essais?: number
|
||||
sortie?: string
|
||||
erreur?: string
|
||||
[cle: string]: unknown
|
||||
}
|
||||
|
||||
const FINS: readonly string[] = ['valide', 'termine', 'echec_controle', 'besoin_claude', 'annule', 'perdue']
|
||||
|
||||
// Demande attendue par une commande : l'id après `/attendre?demande_id=`.
|
||||
export function demandeAttendue(commande: string): string | undefined {
|
||||
return /\/attendre\?demande_id=([\w-]+)/.exec(commande)?.[1]
|
||||
}
|
||||
|
||||
// Commande d'attente active : `sleep`, `Start-Sleep`, `timeout /t`, seuls ou dans une boucle.
|
||||
export const estSommeil = (commande: string) => /(^|[\s;&|(])(sleep|start-sleep|timeout\s+\/t)\b/i.test(commande)
|
||||
|
||||
// Dernière ligne JSON de la sortie de curl, si elle porte un `fin` connu.
|
||||
export function lireFin(sortie: string): Final | undefined {
|
||||
const lignes = sortie.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
|
||||
for (const ligne of lignes.reverse()) {
|
||||
try {
|
||||
const r = JSON.parse(ligne) as Partial<Final>
|
||||
if (r && typeof r === 'object' && typeof r.fin === 'string' && FINS.includes(r.fin)) {
|
||||
return r as Final
|
||||
}
|
||||
} catch {
|
||||
// ligne qui n'est pas du JSON
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Fin d'une réponse directe de `deleguer` ou `suivre` (sans passer par /attendre), seulement quand MIAM a contrôlé :
|
||||
// un refus ordinaire (`ok: false`, aucun preset…) ou un résultat sans `verdict` (ancien MIAM) n'en a pas.
|
||||
export function finDe(r: { ok?: unknown; verdict?: unknown; erreur?: unknown }): Fin | undefined {
|
||||
if (r.ok === true && typeof r.verdict === 'string') {
|
||||
return r.verdict === 'valide' ? 'valide' : 'termine'
|
||||
}
|
||||
if (r.ok === false && typeof r.erreur === 'string' && r.erreur.startsWith('echec_controle')) {
|
||||
return 'echec_controle'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const echecs = (r: Final) => (r.controles ?? []).filter(c => !c.ok).map(c => `${c.nom}${c.message ? ` : ${c.message}` : ''}`)
|
||||
|
||||
// Ce que Claude lit au réveil : le résultat utile et la conduite à tenir, pour qu'il n'ait pas à ouvrir la sortie.
|
||||
export function conduite(r: Final, reprise?: string): string {
|
||||
const id = r.demande_id ? ` ${r.demande_id}` : ''
|
||||
const essais = r.essais ? `, ${r.essais} essai(s)` : ''
|
||||
switch (r.fin) {
|
||||
case 'valide':
|
||||
return `MIAM${id} : verdict valide, contrôles passés${essais}` +
|
||||
(r.sortie ? `, résultat écrit dans ${r.sortie}` : '') +
|
||||
`. Intègre-le sans relire son corps (aperçu ou \`offset\`/\`limit\` si besoin) ; ruff et les tests du projet ` +
|
||||
`suffisent comme vérification.`
|
||||
case 'termine':
|
||||
return `MIAM${id} : terminé sans contrôle automatique. Vérifie le résultat avant de l'intégrer.`
|
||||
case 'echec_controle':
|
||||
return `MIAM${id} : échec des contrôles après toutes les tentatives (${r.erreur ?? echecs(r).join(' ; ')}). ` +
|
||||
`Reprends la tâche toi-même ou redécoupe-la en bouts plus petits, avec ce motif dans la consigne.`
|
||||
case 'besoin_claude':
|
||||
return `MIAM${id} attend une décision de ta part (permission OpenCode, plafond de dépense, consigne ambiguë) : ` +
|
||||
`\`suivre\` pour la voir, réponds (\`autoriser\` pour une permission), puis relance ` +
|
||||
(reprise ? `\`${reprise}\`` : 'la commande `reprise`') + ' en arrière-plan.'
|
||||
case 'annule':
|
||||
return `MIAM${id} : demande annulée, rien à faire.`
|
||||
case 'perdue':
|
||||
return `MIAM${id} : demande perdue (MIAM redémarré ou injoignable). Renvoie-la, ou fais la tâche toi-même.`
|
||||
}
|
||||
}
|
||||
|
||||
export const consigneReprise = (reprise: string) =>
|
||||
`Délégation en cours. Lance une seule fois, avec Bash et \`run_in_background: true\` : \`${reprise}\`. ` +
|
||||
"Puis termine ton tour ou fais autre chose : tu seras réveillé à la fin. Pas de `suivre` ni de `sleep` d'ici là."
|
||||
|
||||
export const REFUS_PREMIER_PLAN = "Attente refusée par le plugin MIAM : lance cette commande en arrière-plan, avec " +
|
||||
"Bash et `run_in_background: true`. Elle bloque jusqu'à la fin de la délégation, et tu seras réveillé à sa sortie."
|
||||
export const REFUS_POWERSHELL = "Attente refusée par le plugin MIAM : la commande `reprise` est en syntaxe POSIX. " +
|
||||
'Lance-la telle quelle avec Bash et `run_in_background: true`.'
|
||||
export const REFUS_SUIVI = 'Suivi refusé par le plugin MIAM : cette délégation est attendue en arrière-plan, tu ' +
|
||||
'seras réveillé à la fin. Termine ton tour ou fais autre chose.'
|
||||
export const REFUS_SOMMEIL = "Attente refusée par le plugin MIAM : une délégation est attendue en arrière-plan et tu " +
|
||||
'seras réveillé à la fin. Pas de `sleep` ni de boucle : termine ton tour ou fais autre chose.'
|
||||
export const REFUS_LECTURE_VALIDE = 'Lecture refusée par le plugin MIAM : ce fichier a passé les contrôles de MIAM ' +
|
||||
`(verdict valide). Lis-en seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus), ou ` +
|
||||
'vérifie-le par ruff et les tests.'
|
||||
|
||||
// Lecture entière d'un fichier : pas de `limit` borné.
|
||||
export const lectureEntiere = (args: { limit?: unknown }) =>
|
||||
!(typeof args.limit === 'number' && args.limit > 0 && args.limit <= PLAGE_MAX)
|
||||
|
||||
// Chemin sensible (concurrence, sécurité) : relu en entier même après un verdict valide. `*` vaut n'importe quels
|
||||
// caractères ; un motif sans `*` désigne la fin du chemin.
|
||||
export const SENSIBLES = ['src/miam/services/mcp.py', 'src/miam/services/instances.py', 'src/miam/core/aio.py', '*sandbox*']
|
||||
|
||||
export function sensible(chemin: string, motifs: readonly string[] = SENSIBLES): boolean {
|
||||
const p = chemin.replace(/\\/g, '/').toLowerCase()
|
||||
return motifs.some(m => {
|
||||
const motif = m.replace(/\\/g, '/').toLowerCase()
|
||||
if (!motif.includes('*')) {
|
||||
return p === motif || p.endsWith(`/${motif}`)
|
||||
}
|
||||
const re = new RegExp(`(^|/)${motif.split('*').map(s => s.replace(/[.+?^${}()|[\]]/g, '\\$&')).join('.*')}$`)
|
||||
return re.test(p)
|
||||
})
|
||||
}
|
||||
|
||||
// Notification de fin d'une commande d'arrière-plan : son fichier de sortie, si elle le nomme.
|
||||
export const fichierSortie = (notification: string) =>
|
||||
/<output-file>\s*([^<]+?)\s*<\/output-file>/.exec(notification)?.[1]
|
||||
@@ -397,3 +397,116 @@ test('pilotage réel : prorata, relecture du diff, requête perdue, et rien pour
|
||||
await requete($) // sans appel de suivi : rien
|
||||
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
|
||||
})
|
||||
|
||||
const BASH = 'Bash'
|
||||
const REPRISE_CMD = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-a"'
|
||||
const NOTIF = '<task-notification><task-id>b9</task-id><output-file>C:/Temp/b9.output</output-file>' +
|
||||
'<status>completed</status></task-notification>'
|
||||
|
||||
// Monte un faux moteur pour l'attente unique ; `finale` est la ligne que la commande reprise écrit à sa sortie.
|
||||
function moteurAttente(on: any, finale: Record<string, unknown>) {
|
||||
const vu = { toasts: [] as string[], contextes: [] as (readonly string[] | undefined)[], appels: [] as string[] }
|
||||
const store = new Map<string, unknown>()
|
||||
const lus: string[] = []
|
||||
on('store.get', (_: unknown, e: { key: string }) => ({ value: store.get(e.key) }))
|
||||
on('store.set', (_: unknown, e: { key: string; value: unknown }) => (store.set(e.key, e.value), { value: undefined }))
|
||||
on('ui.status', () => ({ value: undefined }))
|
||||
on('ui.toast', (_: unknown, e: { text: string }) => (vu.toasts.push(e.text), { value: undefined }))
|
||||
on('session.id', () => ({ value: 's1' }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_: unknown, e: { cwd: string }) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('fs.stat', () => ({ value: { kind: 'file', size: 100 } }))
|
||||
on('fs.read', (_: unknown, e: { path: string }) => { // chemin tel que le moteur le normalise
|
||||
lus.push(e.path)
|
||||
return { value: /b9\.output$/.test(e.path) ? `${JSON.stringify(finale)}\n` : '' }
|
||||
})
|
||||
on('prompt.submit', (_: unknown, e: { text: string; context?: readonly string[] }) =>
|
||||
(vu.contextes.push(e.context), { text: e.text, context: e.context }))
|
||||
on('tool.call', (_: unknown, e: { tool: string }) => {
|
||||
vu.appels.push(e.tool)
|
||||
return e.tool === OUTIL
|
||||
? { result: {}, text: JSON.stringify({ ok: null, demande_id: 'dem-a', etat: 'en_cours', reprise: REPRISE_CMD }) }
|
||||
: e.tool === BASH
|
||||
? { result: { stdout: '', stderr: '', interrupted: false, backgroundTaskId: 'b9' }, text: 'lancée en arrière-plan' }
|
||||
: { result: {}, text: 'contenu' }
|
||||
})
|
||||
return { vu, store, lus }
|
||||
}
|
||||
|
||||
const reveil = ($: any) => $.prompt.submit({ text: NOTIF, wait: false, origin: { kind: 'task-notification' } })
|
||||
|
||||
test('attente unique : reprise en arrière-plan, ni suivre ni sleep, réveil lu par le plugin, résultat valide non relu',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const { vu, store, lus } = moteurAttente(on, {
|
||||
ok: true, demande_id: 'dem-a', tokens_entree: 2100, tokens_sortie: 400, verdict: 'valide', essais: 2,
|
||||
controles: [{ nom: 'lint', ok: true }], sortie: 'E:/p/x.py', fin: 'valide',
|
||||
})
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
|
||||
const delegue = await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400) } as never)
|
||||
expect(delegue.context?.join('\n')).toContain('run_in_background: true')
|
||||
expect(delegue.context?.join('\n')).toContain(REPRISE_CMD)
|
||||
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toContain('arrière-plan')
|
||||
expect((await $.tool.call({ tool: 'PowerShell', command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||
.toContain('Bash')
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||
.toBeUndefined()
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toContain('réveillé')
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 60' } as never)).deny).toContain('sleep')
|
||||
expect(vu.appels.filter(t => t === SUIVRE)).toEqual([]) // refusé avant d'atteindre MIAM
|
||||
|
||||
await reveil($)
|
||||
expect(lus.some(p => /b9\.output$/.test(p))).toBe(true) // la sortie nommée par la notification
|
||||
const lu = vu.contextes.at(-1)?.join('\n') ?? ''
|
||||
expect(lu).toContain('inutile de')
|
||||
expect(lu).toContain('verdict valide')
|
||||
expect(lu).toContain('E:/p/x.py')
|
||||
expect(vu.toasts.at(-1)).toContain('tokens évités')
|
||||
expect((store.get('cumul') as { delegations: number }).delegations).toBe(1)
|
||||
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py' } as never)).deny).toContain('verdict valide')
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py', offset: 1, limit: 40 } as never)).deny)
|
||||
.toBeUndefined()
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined() // fini
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 1' } as never)).deny).toBeUndefined()
|
||||
|
||||
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||
expect((bilan as { text: string }).text).toContain('1 attente(s) unique(s), 2 essai(s), 0 échec(s)')
|
||||
})
|
||||
|
||||
test('attente unique : besoin_claude rouvre suivre, echec_controle compte sans gain, un sous-agent n’est pas freiné',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const finale: Record<string, unknown> = { ok: null, demande_id: 'dem-a', fin: 'besoin_claude' }
|
||||
const { vu, store } = moteurAttente(on, finale)
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 5', agentId: 'a1' } as never)).deny).toBeUndefined()
|
||||
|
||||
await reveil($)
|
||||
expect(vu.contextes.at(-1)?.join('\n')).toContain(REPRISE_CMD) // relancer après avoir répondu
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
Object.assign(finale, { ok: false, fin: 'echec_controle', erreur: 'echec_controle : symboles : champ x inventé' })
|
||||
await reveil($)
|
||||
expect(vu.contextes.at(-1)?.join('\n')).toContain('symboles : champ x inventé')
|
||||
expect(store.get('cumul')).toBeUndefined() // aucun gain compté
|
||||
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||
expect((bilan as { text: string }).text).toContain('2 attente(s) unique(s), 0 essai(s), 1 échec(s)')
|
||||
})
|
||||
|
||||
test('imposer=false : l’attente est suivie mais rien n’est refusé', { options: { jeton: 'x', imposer: false } },
|
||||
async ($, on) => {
|
||||
moteurAttente(on, {})
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toBeUndefined()
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||
})
|
||||
+161
-25
@@ -5,6 +5,11 @@ import {
|
||||
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, repartir, retirer, VIDE,
|
||||
} from './economies'
|
||||
import type { Evenement, Suivi, Totaux } from './economies'
|
||||
import {
|
||||
conduite, consigneReprise, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin,
|
||||
REFUS_LECTURE_VALIDE, REFUS_POWERSHELL, REFUS_PREMIER_PLAN, REFUS_SOMMEIL, REFUS_SUIVI, sensible, SENSIBLES,
|
||||
} from './attente'
|
||||
import type { Fin } from './attente'
|
||||
import { lectureTropLongue, nomSortie, PLAGE_MAX, sortieTropLongue } from './contrainte'
|
||||
import { fenetres, ligneFenetre } from './usage'
|
||||
|
||||
@@ -103,6 +108,78 @@ async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// État des délégations de la session, partagé par les hooks et les fonctions ci-dessous.
|
||||
type Delegations = {
|
||||
ecran: Ecran
|
||||
enFile: Map<string, Demande>
|
||||
comptees: Set<string>
|
||||
subagents: Map<string, string[]>
|
||||
sorties: Map<string, string>
|
||||
opencode: Map<string, { pilotage: number; evites: number }>
|
||||
attentes: Map<string, 'reprise' | 'attendue' | 'besoin_claude'>
|
||||
commandesReprise: Map<string, string>
|
||||
finies: Set<string>
|
||||
valides: Set<string>
|
||||
mesure: { attentes: number; essais: number; echecs: number }
|
||||
}
|
||||
|
||||
// État final d'une délégation (réponse directe, ou sortie de /attendre au réveil) : gain, compteurs, et la conduite
|
||||
// à tenir, que Claude lit à la place de la réponse. Rien quand MIAM n'a rien contrôlé et que rien n'a été attendu.
|
||||
async function terminer($: EngineInterface, d: Delegations, r: Record<string, unknown>, demande: Demande | undefined):
|
||||
Promise<string | undefined> {
|
||||
const id = typeof r.demande_id === 'string' ? r.demande_id : undefined
|
||||
const fin = (typeof r.fin === 'string' ? r.fin : finDe(r)) as Fin | undefined
|
||||
if (id && fin === 'besoin_claude') {
|
||||
d.attentes.set(id, 'besoin_claude')
|
||||
} else if (id && fin) {
|
||||
d.attentes.delete(id)
|
||||
if (!d.finies.has(id)) {
|
||||
d.finies.add(id)
|
||||
d.mesure.echecs += fin === 'echec_controle' ? 1 : 0
|
||||
d.mesure.essais += typeof r.essais === 'number' ? r.essais : 0
|
||||
}
|
||||
}
|
||||
if (fin === 'valide' && typeof r.sortie === 'string') {
|
||||
d.valides.add(cheminNormal(r.sortie))
|
||||
}
|
||||
if (demande) {
|
||||
await compter($, d, r, demande)
|
||||
}
|
||||
return fin ? conduite({ ...r, fin }, id ? d.commandesReprise.get(id) : undefined) : undefined
|
||||
}
|
||||
|
||||
// Gain d'une délégation finie, compté une fois (suivre peut renvoyer deux fois un résultat).
|
||||
async function compter($: EngineInterface, d: Delegations, texte: unknown, demande: Demande): Promise<void> {
|
||||
const r = lireResultat(texte)
|
||||
if (!r?.demande_id || d.comptees.has(r.demande_id)) {
|
||||
return
|
||||
}
|
||||
d.comptees.add(r.demande_id)
|
||||
d.enFile.delete(r.demande_id)
|
||||
const fichier = r.sortie ?? r.diff
|
||||
if (fichier) {
|
||||
d.sorties.set(cheminNormal(fichier), r.demande_id)
|
||||
}
|
||||
if (demande.agentId) {
|
||||
d.subagents.set(demande.agentId, [...(d.subagents.get(demande.agentId) ?? []), r.demande_id])
|
||||
}
|
||||
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
|
||||
await afficher($, d.ecran, { delegations: 1, evites: est.evites })
|
||||
const pilote = d.opencode.get(r.demande_id)
|
||||
if (pilote) {
|
||||
pilote.evites = est.evites
|
||||
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
|
||||
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
|
||||
} else {
|
||||
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
|
||||
`${court(est.ecrits)} écrits)`)
|
||||
}
|
||||
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
|
||||
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
|
||||
{ ...r, demande_id: r.demande_id }, est)])
|
||||
}
|
||||
|
||||
|
||||
export const register: Register = (on, options) => {
|
||||
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
|
||||
const ecran: Ecran = { session: VIDE, limites: [] }
|
||||
@@ -113,11 +190,24 @@ export const register: Register = (on, options) => {
|
||||
const opencode = new Map<string, { pilotage: number; evites: number }>() // délégations à OpenCode de cette session
|
||||
let suivis: Suivi[] = [] // appels de suivi d'OpenCode à imputer à la prochaine requête
|
||||
let appels = 0 // outils lancés par la dernière requête de l'agent principal
|
||||
// Attente unique : demande → « reprise » reçue, « attendue » (curl en arrière-plan) ou « besoin_claude ».
|
||||
const attentes = new Map<string, 'reprise' | 'attendue' | 'besoin_claude'>()
|
||||
const commandesReprise = new Map<string, string>() // demande_id → sa commande `reprise`
|
||||
const taches = new Map<string, string>() // id de la commande d'arrière-plan → demande_id attendue
|
||||
const finies = new Set<string>() // demande_id dont l'état final est déjà pris en compte
|
||||
const valides = new Set<string>() // fichiers `sortie` au verdict valide (chemin normalisé)
|
||||
const mesure = { attentes: 0, essais: 0, echecs: 0 } // pour la mesure de la v0.9.0, par session
|
||||
const d: Delegations = {
|
||||
ecran, enFile, comptees, subagents, sorties, opencode, attentes, commandesReprise, finies, valides, mesure,
|
||||
}
|
||||
let reprises = 0 // id de secours d'une correction sans tool_use_id
|
||||
let envoiEnCours = false
|
||||
let dernierRappel = -Infinity // date du dernier rappel envoyé au modèle
|
||||
const incitation = options.incitation !== false
|
||||
const imposer = options.imposer !== false
|
||||
const motifsSensibles = typeof options.chemins_sensibles === 'string'
|
||||
? options.chemins_sensibles.split(/[,;]/).map(m => m.trim()).filter(Boolean)
|
||||
: SENSIBLES
|
||||
let dossierSorties = DOSSIER_SORTIES
|
||||
|
||||
on('session.start', async ($, e, next) => {
|
||||
@@ -155,6 +245,8 @@ export const register: Register = (on, options) => {
|
||||
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
|
||||
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
|
||||
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
|
||||
`Contrôles de la session : ${mesure.attentes} attente(s) unique(s), ${mesure.essais} essai(s), ` +
|
||||
`${mesure.echecs} échec(s) de contrôle.`,
|
||||
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
|
||||
'OpenCode : tokens du cloud moins la consigne. Pilotage = consommation du subagent (lecture en cache comptée ' +
|
||||
'au dixième) et, pour OpenCode, usage réel des requêtes de suivi, contexte relu compris ; corrections = ce que ' +
|
||||
@@ -167,6 +259,33 @@ export const register: Register = (on, options) => {
|
||||
on('tool.call', async ($, e, next) => {
|
||||
const outil = OUTIL.exec(e.tool)?.[1]
|
||||
if (!outil) {
|
||||
// Attente unique : la commande `reprise` part une fois en arrière-plan, et rien ne guette la fin à sa place.
|
||||
if (e.agentId === undefined && SHELL.test(e.tool)) {
|
||||
const shell = e as unknown as { command?: unknown; run_in_background?: unknown }
|
||||
const commande = String(shell.command ?? '')
|
||||
const attendue = demandeAttendue(commande)
|
||||
if (attendue) {
|
||||
if (imposer && e.tool !== 'Bash') {
|
||||
return { deny: REFUS_POWERSHELL }
|
||||
}
|
||||
if (imposer && shell.run_in_background !== true) {
|
||||
return { deny: REFUS_PREMIER_PLAN }
|
||||
}
|
||||
const ran = await next(e)
|
||||
if (ran.deny === undefined && !ran.isError) {
|
||||
attentes.set(attendue, 'attendue')
|
||||
mesure.attentes += 1
|
||||
const tache = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
|
||||
if (typeof tache === 'string') {
|
||||
taches.set(tache, attendue)
|
||||
}
|
||||
}
|
||||
return ran
|
||||
}
|
||||
if (imposer && estSommeil(commande) && [...attentes.values()].includes('attendue')) {
|
||||
return { deny: REFUS_SOMMEIL }
|
||||
}
|
||||
}
|
||||
// Corrections : l'agent principal relit ou retouche une sortie de MIAM ; ce coût se déduit du gain. Celles du
|
||||
// subagent sont déjà dans son pilotage.
|
||||
if (e.agentId !== undefined || !REPRISE.test(e.tool)) {
|
||||
@@ -174,6 +293,12 @@ export const register: Register = (on, options) => {
|
||||
}
|
||||
const args = e as unknown as Record<string, unknown>
|
||||
const demandeId = sorties.size ? cible(e.tool, args, sorties) : undefined
|
||||
// Un résultat au verdict valide ne se relit pas en entier, sauf sur un chemin sensible.
|
||||
const chemin = String(args.file_path ?? '')
|
||||
if (imposer && e.tool === 'Read' && valides.has(cheminNormal(chemin)) && lectureEntiere(args) &&
|
||||
!sensible(chemin, motifsSensibles)) {
|
||||
return { deny: REFUS_LECTURE_VALIDE }
|
||||
}
|
||||
// Un long fichier ne se lit pas en entier, sauf s'il vient de MIAM : le refus part avant la lecture.
|
||||
if (imposer && e.tool === 'Read' && !demandeId) {
|
||||
const stat = await $.fs.stat(String(args.file_path), { resolve: false }).catch(() => undefined)
|
||||
@@ -219,6 +344,11 @@ export const register: Register = (on, options) => {
|
||||
}, correction)])
|
||||
return ran
|
||||
}
|
||||
// Délégation attendue en arrière-plan : le réveil viendra seul. Une décision demandée (besoin_claude) reste ouverte.
|
||||
const demandeSuivie = (e as unknown as { demande_id?: string }).demande_id ?? ''
|
||||
if (imposer && outil === 'suivre' && e.agentId === undefined && attentes.get(demandeSuivie) === 'attendue') {
|
||||
return { deny: REFUS_SUIVI }
|
||||
}
|
||||
const ran = await next(e)
|
||||
if (ran.deny !== undefined || ran.isError) {
|
||||
return ran
|
||||
@@ -258,37 +388,43 @@ export const register: Register = (on, options) => {
|
||||
return ran
|
||||
}
|
||||
if (brut?.ok == null && brut?.demande_id) {
|
||||
enFile.set(brut.demande_id, demande) // en file : compté quand suivre rendra le résultat
|
||||
enFile.set(brut.demande_id, demande) // en file : compté au résultat (suivre, ou réveil de l'attente)
|
||||
const reprise = (brut as { reprise?: unknown }).reprise
|
||||
if (typeof reprise === 'string' && e.agentId === undefined) {
|
||||
attentes.set(brut.demande_id, 'reprise')
|
||||
commandesReprise.set(brut.demande_id, reprise)
|
||||
return { ...ran, context: [...(ran.context ?? []), consigneReprise(reprise)] }
|
||||
}
|
||||
return ran
|
||||
}
|
||||
const r = lireResultat(texte)
|
||||
if (!r?.demande_id || comptees.has(r.demande_id)) {
|
||||
return ran
|
||||
const conseil = await terminer($, d, texte as Record<string, unknown>, demande)
|
||||
return conseil && e.agentId === undefined ? { ...ran, context: [...(ran.context ?? []), conseil] } : ran
|
||||
})
|
||||
|
||||
// Réveil : la commande `reprise` est finie. Le plugin lit sa sortie (une ligne JSON avec `fin`) et donne à Claude le
|
||||
// résultat et la conduite, pour qu'il n'ait ni à ouvrir cette sortie ni à appeler `suivre`.
|
||||
on('prompt.submit', async ($, e, next) => {
|
||||
// Reconnue à l'id de la commande d'arrière-plan relevé au lancement, ou à son enveloppe qui cite la route.
|
||||
if (!attentes.size) {
|
||||
return next(e)
|
||||
}
|
||||
comptees.add(r.demande_id)
|
||||
enFile.delete(r.demande_id)
|
||||
const fichier = r.sortie ?? r.diff
|
||||
if (fichier) {
|
||||
sorties.set(cheminNormal(fichier), r.demande_id)
|
||||
const tache = [...taches].find(([t]) => e.text.includes(t))
|
||||
const id = tache?.[1] ?? (e.text.includes('<task-notification') ? demandeAttendue(e.text) : undefined)
|
||||
if (!id || attentes.get(id) !== 'attendue') {
|
||||
return next(e)
|
||||
}
|
||||
if (demande.agentId) {
|
||||
subagents.set(demande.agentId, [...(subagents.get(demande.agentId) ?? []), r.demande_id])
|
||||
if (tache) {
|
||||
taches.delete(tache[0])
|
||||
}
|
||||
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
|
||||
await afficher($, ecran, { delegations: 1, evites: est.evites })
|
||||
const pilote = opencode.get(r.demande_id)
|
||||
if (pilote) {
|
||||
pilote.evites = est.evites
|
||||
$.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
|
||||
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
|
||||
} else {
|
||||
$.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
|
||||
`${court(est.ecrits)} écrits)`)
|
||||
const chemin = fichierSortie(e.text)
|
||||
const final = chemin ? lireFin(await $.fs.read(chemin).catch(() => '')) : undefined
|
||||
if (!final) {
|
||||
attentes.delete(id) // sortie illisible : Claude la lit lui-même, et `suivre` redevient possible
|
||||
return next(e)
|
||||
}
|
||||
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
|
||||
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
|
||||
{ ...r, demande_id: r.demande_id }, est)])
|
||||
return ran
|
||||
const conseil = await terminer($, d, { demande_id: id, ...final }, enFile.get(id))
|
||||
const lu = `Sortie de l'attente lue par le plugin MIAM, inutile de l'ouvrir. ${conseil}`
|
||||
return next({ ...e, context: [...(e.context ?? []), lu] })
|
||||
})
|
||||
|
||||
on('turn.step', async function* ($, e, next) {
|
||||
|
||||
@@ -30,9 +30,31 @@ pilotage coûte plus de tokens que la délégation n'en économise. Le modèle l
|
||||
- `fichiers` : **chemins absolus**, `chemin:debut-fin` pour une partie. MIAM les lit : ne les lis pas avant ;
|
||||
- `format` : `json` pour des faits extraits, `code` pour du code, sinon `texte` ;
|
||||
- réponse longue : `sortie` + `retour="apercu"`, pour ne pas la relire ;
|
||||
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement.
|
||||
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement ;
|
||||
- `controle` : contrôles de MIAM parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`
|
||||
(`[]` pour aucun). Par défaut, du code avec une `sortie` `.py` passe `syntaxe`, `complet`, `symboles`, `docstrings` et
|
||||
`lint` ; le reste passe `complet` ;
|
||||
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet` ;
|
||||
- `symboles` et `citations` comparent la réponse aux `fichiers` : joins les modules que le code utilise.
|
||||
|
||||
Réponse « en file » : `suivre(demande_id, attente_s=45)` jusqu'au résultat, `annuler(demande_id)` pour abandonner.
|
||||
MIAM retente seul en cas d'échec (2 essais, puis l'API pour une demande locale) et n'écrit la `sortie` qu'après un
|
||||
verdict `valide`.
|
||||
|
||||
**Réponse « en file »** (`ok: null` avec `reprise`) : lance la commande `reprise` **une seule fois**, telle quelle, avec
|
||||
Bash et `run_in_background: true`, puis termine ton tour ou fais autre chose. Tu es réveillé à la fin, et le plugin te
|
||||
donne alors le résultat : inutile d'ouvrir la sortie de la commande. Ni `suivre` ni `sleep` d'ici là (le plugin les
|
||||
refuse). Au réveil, selon `fin` :
|
||||
|
||||
| `fin` | Conduite |
|
||||
|---|---|
|
||||
| `valide` | contrôles passés : intègre sans relire le corps (aperçu ou `offset`/`limit`), ruff et tests du projet ensuite |
|
||||
| `termine` | pas de contrôle automatique : vérifie avant d'intégrer |
|
||||
| `echec_controle` | reprends la main, ou redécoupe en bouts plus petits avec le motif dans la consigne |
|
||||
| `besoin_claude` | `suivre` pour voir la décision attendue, réponds (`autoriser`), puis relance `reprise` en arrière-plan |
|
||||
| `annule` | rien |
|
||||
| `perdue` | MIAM redémarré ou injoignable : renvoie la demande ou fais-la toi-même |
|
||||
|
||||
Sans `reprise` (ancien MIAM) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. `annuler(demande_id)` abandonne.
|
||||
|
||||
Refus de `deleguer` : `aucun preset testé` → `demarrer(preset="${user_config.preset_defaut}")`, `suivre`, puis le même
|
||||
`deleguer` ; contexte dépassé → `modeles`, puis `preset` = le mieux noté dont le `contexte` suffit, sans `offload` si
|
||||
|
||||
Reference in new issue
Block a user